Taming TypeScript Errors: Patterns That Actually Help
TypeScript errors are either a wall of red noise or a useful guide. The difference is how you model errors. Here's the playbook.
You write a function. It fetches data, parses it, transforms it. Three places it can fail. You have three options:
- Let it throw — trust the caller to wrap in try/catch
- Return
nullon failure — caller checks - Return a
Result<T, E>type — caller pattern-matches
In TypeScript, all three are common. They're not equivalent. Picking poorly causes the production bugs that bite you six months later.
The default: throw
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`Failed: ${response.status}`);
return response.json();
}
TypeScript doesn't track exceptions. Callers have no compiler help to know this can throw. You have to read the code or remember.
Pros:
- Idiomatic JavaScript
- Stack traces work
- Compose easily (errors propagate up)
Cons:
- Type system gives you no information about failure modes
- Easy to forget try/catch
- "Unhandled promise rejection" in production
Use throw for: actually exceptional cases. Programmer errors. Things that should crash.
Don't use throw for: expected business outcomes (user not found, validation failed). Those aren't exceptional. Returning them is clearer.
Returning null / undefined
async function fetchUser(id: string): Promise<User | null> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) return null;
return response.json();
}
Caller is forced by the type system to handle null:
const user = await fetchUser(id);
if (!user) {
// ... handle
return;
}
user.email; // ok, narrowed to User
Pros:
- Type-safe — compiler catches missing handling
- Simple
Cons:
- Loses the reason for failure (was it 404? 500? network?)
- Doesn't compose well with chains (
.then(...)becomes ugly)
Use null for: simple optional cases, where the caller doesn't care why it failed.
Result types
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
async function fetchUser(id: string): Promise<Result<User, FetchError>> {
try {
const response = await fetch(`/api/users/${id}`);
if (response.status === 404) return { ok: false, error: { type: 'not_found' } };
if (!response.ok) return { ok: false, error: { type: 'server_error', status: response.status } };
return { ok: true, value: await response.json() };
} catch (e) {
return { ok: false, error: { type: 'network_error', cause: e } };
}
}
Caller:
const result = await fetchUser(id);
if (!result.ok) {
switch (result.error.type) {
case 'not_found': return showNotFound();
case 'server_error': return showRetry();
case 'network_error': return showOffline();
}
}
result.value.email;
Pros:
- Type-safe
- Carries failure reasons
- Forces caller to handle each case
- Composes well (functional combinators)
Cons:
- Verbose (TypeScript is not Rust)
- New abstraction for the team
- Awkward to use existing libs that throw
Use Result for: business logic where failure modes matter and have specific handling. API client functions. Domain operations.
The pragmatic rule
Three categories of errors, three patterns:
Programmer errors (typo'd a key, called function with wrong type, invariant violated): throw. These should crash.
Expected business outcomes (user not found, email already taken, payment declined): return Result with typed error variants. The caller cares about the type.
Optional values (looking up something that may or may not exist): return T | null. No reason needed.
Mix them in the same codebase. Don't force one pattern everywhere.
A tiny Result implementation
Don't pull in a full FP library if you don't need it. This is enough:
export type Ok<T> = { ok: true; value: T };
export type Err<E> = { ok: false; error: E };
export type Result<T, E> = Ok<T> | Err<E>;
export const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
export const err = <E>(error: E): Err<E> => ({ ok: false, error });
export const isOk = <T, E>(r: Result<T, E>): r is Ok<T> => r.ok;
export const isErr = <T, E>(r: Result<T, E>): r is Err<E> => !r.ok;
For most teams, this is enough. Add combinators (map, flatMap, getOrElse) only when you find you're writing them by hand repeatedly.
Discriminated union errors
The big win of Result over throw is typed errors. Use discriminated unions:
type FetchError =
| { type: 'not_found' }
| { type: 'server_error'; status: number }
| { type: 'network_error'; cause: unknown }
| { type: 'parse_error'; message: string };
Now the compiler can verify you handled every variant in a switch:
function describe(e: FetchError): string {
switch (e.type) {
case 'not_found': return 'Not found';
case 'server_error': return `Server error: ${e.status}`;
case 'network_error': return 'Network error';
// forgot 'parse_error' — compile error
}
}
Use exhaustiveness checking via the never trick:
function describe(e: FetchError): string {
switch (e.type) {
case 'not_found': return 'Not found';
case 'server_error': return `Server error: ${e.status}`;
case 'network_error': return 'Network error';
case 'parse_error': return e.message;
default: return e satisfies never;
}
}
Now adding a new variant breaks the build. Compile errors find every place that needs updating.
What about libraries that throw?
You're using fetch, JSON.parse, third-party SDKs that throw. You can't avoid it.
Wrap at the boundary:
function safe<T>(fn: () => T): Result<T, Error> {
try {
return ok(fn());
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)));
}
}
const json = safe(() => JSON.parse(rawText));
For async:
async function safeAsync<T>(fn: () => Promise<T>): Promise<Result<T, Error>> {
try {
return ok(await fn());
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)));
}
}
Boundary functions catch the throws. Inside your code, errors flow as types. The "throw" half is contained.
The takeaway
Don't let throw be your default for everything. Categorize: programmer errors (throw), expected outcomes (Result), optional (null). Use discriminated unions for error types. The result is a codebase where errors are visible to the compiler instead of lurking until production. The TypeScript compiler is your best friend if you let it know about your errors.
Work with me
I consult with engineering teams on AI adoption, cloud architecture, and engineering effectiveness. If this post surfaced a challenge you're facing, let's talk.
Get in touch →Related posts
Explore more on these topics: