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.
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.
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.
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.
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.
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
| Aspect | Throwing errors | Result types |
|---|---|---|
| Failure visible in signature | No | Yes |
| Compiler enforces handling | No | Yes |
| Caller must remember to catch | Yes, manually | No, compiler forces it |
| Stack trace available | Yes | No, unless you create one |
| Performance of failure path | Slow (stack walk) | Fast (plain return) |
| Fits expected failures | Clunky | Natural |
| Fits unexpected crashes | Natural | Clunky |
| Works with async | Yes | Yes |
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.
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.
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.
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.
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.
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
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.
Frequently Asked Questions
Can I use both throwing and Result types in the same codebase?
Does Result work with async functions?
What is the performance difference between throwing and Result?
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.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.