Throwing Errors vs Result Types in TypeScript

Compare two ways to handle errors in TypeScript: throwing exceptions and returning Result types. Learn when each approach works best and how to combine them in real applications.

7 min read

TypeScript gives you two fundamentally different ways to signal that something went wrong. You can throw an error, which jumps out of the normal control flow and must be caught somewhere up the call stack. Or you can return a Result object, which carries either the success value or the error and forces the caller to handle both cases before using the result.

The difference is not just style. Each approach makes different guarantees visible to the type system and has different implications for how callers write code.

How Each Approach Works

Both approaches solve the same problem, signaling failure, but they put the responsibility in different places. Throwing uses try/catch to separate the success and error paths.

typescripttypescript
function divide(a: number, b: number): number {
  if (b === 0) throw new Error("Cannot divide by zero");
  return a / b;
}
 
try {
  const result = divide(10, 0);
  console.log(result);
} catch (err) {
  console.error("Division failed");
}

The return type number says nothing about the possibility of failure. The caller must remember to wrap the call in try/catch, or the error propagates up and potentially crashes the program.

Returning a Result makes the failure explicit in the type signature, starting with the type itself.

typescripttypescript
type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };
 
function divide(a: number, b: number): Result<number> {
  if (b === 0) return { ok: false, error: new Error("Cannot divide by zero") };
  return { ok: true, value: a / b };
}

The caller checks the discriminant before touching either side of the result, since accessing the wrong branch is a compile-time error rather than a runtime surprise.

typescripttypescript
const result = divide(10, 0);
 
if (result.ok) {
  console.log(result.value);
} else {
  console.error(result.error.message);
}

The return type Result<number> tells every caller that this function can fail. The caller cannot access the value without first checking ok. TypeScript enforces this at compile time.

Throw vs Result flow comparison

The diagram shows the structural difference. In the throw path, the error jumps to a separate scope. In the Result path, both outcomes are handled in the same control flow and the compiler verifies both are addressed.

Comparison Table

AspectThrowing errorsResult types
Failure visible in signatureNoYes
Compiler enforces handlingNoYes
Caller must remember to catchYes, manuallyNo, compiler forces it
Stack trace availableYesNo, unless you create one
Performance of failure pathSlow (stack walk)Fast (plain return)
Fits expected failuresClunkyNatural
Fits unexpected crashesNaturalClunky
Works with asyncYesYes

The key trade-off is visibility versus effort. Throwing is less code to write but relies on programmer discipline. Result types are more code but make failure impossible to ignore.

When to Use Result Types

Use Result when failure is an expected outcome that the caller should handle. These are cases where the function has a defined success path and a defined failure path, and both are part of the normal operation of the application.

Expected failures include form validation, parsing user input, API calls that might return 404, file operations where the file might not exist, and any operation where the user or external system controls the outcome.

typescripttypescript
function parsePort(input: string): Result<number> {
  const parsed = parseInt(input, 10);
  if (isNaN(parsed) || parsed < 1 || parsed > 65535) {
    return { ok: false, error: new Error(`Invalid port: ${input}`) };
  }
  return { ok: true, value: parsed };
}

The caller knows port parsing can fail and is forced to handle it.

When to Throw Errors

Use throwing when the situation is truly exceptional and the caller cannot reasonably recover. These are cases where something is broken in a way that no amount of error handling at the call site can fix.

Unexpected failures include missing required environment variables, corrupted internal state, programming bugs like null references where a value should always exist, and invariant violations that indicate a logic error.

typescripttypescript
function getDatabaseUrl(): string {
  const url = process.env.DATABASE_URL;
  if (!url) {
    throw new Error("DATABASE_URL environment variable is required");
  }
  return url;
}

The application cannot function without the database URL. A Result type would be misleading here because there is no meaningful recovery path, since the caller cannot fix a missing environment variable.

Combining Both Approaches

Real applications use both. Validate at the boundary with Results, then throw for internal invariants once data is trusted.

typescripttypescript
function validateAndProcess(input: string): string {
  const validated = validateInput(input);
  if (!validated.ok) {
    return validated.error.message;
  }
  processTrustedData(validated.value);
  return "Processed successfully";
}

A second function assumes the data is already trusted and throws only if that assumption turns out to be wrong.

typescripttypescript
function processTrustedData(data: ValidData): void {
  if (!data.id) {
    throw new Error("ValidData must have an id at this point");
  }
  saveToDatabase(data);
}

validateAndProcess uses a Result at the boundary where bad input is expected. processTrustedData throws for an invariant that should never happen because validation already ran. The throw here signals a programming bug, not a user error.

Async Considerations

Both approaches work with async functions. The choice depends on the same criteria.

typescripttypescript
async function fetchUser(id: string): Promise<Result<User>> {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      return { ok: false, error: new Error(`HTTP ${response.status}`) };
    }
    return { ok: true, value: await response.json() };
  } catch (err) {
    return { ok: false, error: new Error("Network request failed") };
  }
}

The caller awaits and then checks ok, just like the synchronous pattern.

For more on the Result pattern implementation, see Result pattern in TypeScript. For handling caught errors safely, see unknown errors in TypeScript.

Rune AI

Rune AI

Key Insights

  • Throwing hides the error path from the function signature; Result makes it explicit.
  • Use Result for expected, recoverable failures like validation and network errors.
  • Use throw for unexpected, unrecoverable situations like missing config or bugs.
  • Results are faster than throw/catch because they avoid stack trace creation.
  • Combine both: validate with Results at boundaries, throw for internal invariants.
RunePowered by Rune AI

Frequently Asked Questions

Can I use both throwing and Result types in the same codebase?

Yes, and most applications do. Use Results for expected failures like validation and network errors. Use throws for truly unexpected situations like missing configuration or programming bugs. The boundary between the two is where you decide if the caller can reasonably recover.

Does Result work with async functions?

Yes. An async function can return Promise of Result, which makes the potential failure visible in the return type. The caller awaits the promise and then checks ok, exactly like the synchronous pattern.

What is the performance difference between throwing and Result?

Throwing and catching is significantly slower than returning a value because it walks the call stack and creates a stack trace. For errors that happen in hot loops, Result is measurably faster. For most application code, the difference is negligible compared to network and database latency.

Conclusion

Throwing and Result types solve different problems. Use Results when failure is expected and the caller should handle it explicitly. Use throws when something is genuinely wrong and the caller cannot recover. A good codebase uses both: Results at public API boundaries where callers need to handle failures, and throws deep in internal logic for invariant violations.