Try-catch blocks are the "goto" of error handling.
-
Three problems:
1. No error contract.
↳ The signature says nothing about what can throw. You find out by reading the implementation. or by getting paged at 2 AM.
2. Control flow is invisible.
↳ A throw jumps to the nearest catch, which might be three frames up.
3. Exhaustiveness is guesswork.
↳ Forgot a case? The compiler won't tell you.
TypeScript can fix this with a discriminated union.
The Result type + helpers.
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
The compiler enforces exhaustive handling.
Now errors live in the return type:
function createUser(
email: string,
): Result<User, 'INVALID_EMAIL' | 'DUPLICATE'>
No try-catch. The compiler makes callers handle every case.
-
When to use which:
→ Try-catch for unexpected failures — database connection drops, file system errors, out-of-memory. Things you can't recover from gracefully.
→ Result pattern for expected failures — validation, business rules, permission checks. Things your code knows about and should handle explicitly.
Errors are data, not control flow.
Once you start treating them that way, your code gets simpler, your types get honest, and your 2 AM pages get rarer.
What pattern do you use for error handling in TypeScript?
——
💾 Save this for later.
♻ Restack to help others find it.
➕ Follow Petar Ivanov + turn on notifications.