Skip to main content
← All posts
5 min read

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.

Share

You write a function. It fetches data, parses it, transforms it. Three places it can fail. You have three options:

  1. Let it throw — trust the caller to wrap in try/catch
  2. Return null on failure — caller checks
  3. 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 →

Explore more on these topics:

Subscribe to new posts

Get an email when I publish something new. No spam, unsubscribe any time.