All posts
JavaScript & TypeScript··9 min read

Stop Throwing Strings: Typed Error Handling in TypeScript

try/catch loses type information the moment an error is thrown. Here is how I handle errors in TypeScript with typed errors and Result types.

By

On this page

Open any TypeScript codebase and look at a catch block. The error binding is typed unknown (or any if someone never enabled useUnknownInCatchVariables, which has been the default since 4.4). That is not a quirk — it is the type system telling you the truth. TypeScript has no idea what a function might throw, because throw is invisible to the type checker. A function that can blow up in five different ways has the exact same signature as one that can't fail at all.

That gap is where production incidents live. You write catch (err), reach for err.message, and three months later a dependency throws a string instead of an Error, your .message is undefined, your log line is useless, and your pager is going off at 2am. I have shipped that bug. Several times. Here is how I stopped.

The actual problem: throws are off the type graph

Consider this signature:

async function chargeCard(customerId: string, cents: number): Promise<Receipt>

What can go wrong? The customer might not exist. The card might be declined. Stripe might time out. Your own validation might reject a negative amount. The type says: returns a Receipt, period. Every failure mode is erased. The caller has no compiler-enforced reason to handle any of them, and Promise<Receipt> reads like a promise that always succeeds.

When you do catch, the variable is unknown, so you cannot touch it without narrowing first:

try {
  await chargeCard(id, 5000);
} catch (err) {
  // err is `unknown` — this line does not compile:
  // console.error(err.message);
  console.error(err instanceof Error ? err.message : String(err));
}

useUnknownInCatchVariables forcing you through instanceof is a feature, not a nuisance. The compiler is refusing to let you assume structure you cannot prove. The fix is to stop assuming and start encoding.

Step one: a discriminated error hierarchy

Subclassing Error is fine, but plain subclasses are awkward to narrow — instanceof chains get long and don't survive serialization across a worker or network boundary. I add a literal discriminant field so I can switch on it like any other tagged union.

abstract class AppError extends Error {
  abstract readonly kind: string;
 
  constructor(message: string, options?: { cause?: unknown }) {
    super(message, options);
    this.name = new.target.name;
  }
}
 
export class NotFoundError extends AppError {
  readonly kind = "not_found" as const;
  constructor(public readonly resource: string, public readonly id: string) {
    super(`${resource} ${id} not found`);
  }
}
 
export class CardDeclinedError extends AppError {
  readonly kind = "card_declined" as const;
  constructor(public readonly declineCode: string) {
    super(`card declined: ${declineCode}`);
  }
}
 
export class ValidationError extends AppError {
  readonly kind = "validation" as const;
  constructor(public readonly issues: string[]) {
    super(`validation failed: ${issues.join(", ")}`);
  }
}
 
export type ChargeError = NotFoundError | CardDeclinedError | ValidationError;

The kind field gives me exhaustive narrowing without instanceof gymnastics, and it survives JSON.stringify so an error that crosses a queue or RPC boundary keeps its identity. Note options?.cause — passing { cause } is the standard way to chain the original failure (since ES2022) instead of stuffing it into a custom field. Always preserve the cause; it is the difference between a debuggable stack and a dead end.

A small but real footgun: if you target ES5 or ES2015 without useDefineForClassFields set correctly, instanceof against Error subclasses can silently break because the prototype chain gets clobbered by the transpiler. Targeting ES2022+ (which you should in 2026) makes this a non-issue.

Step two: make failure part of the signature with Result

Subclassed errors fix narrowing, but they don't fix the invisibility problem — a thrown ChargeError still doesn't appear in the function signature. For expected failures, I return them instead. A Result<T, E> is a discriminated union of success and failure:

export type Result<T, E> =
  | { readonly ok: true; readonly value: T }
  | { readonly ok: false; readonly error: E };
 
export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
 
export function isOk<T, E>(r: Result<T, E>): r is { ok: true; value: T } {
  return r.ok;
}

Now the signature tells the whole truth:

async function chargeCard(
  customerId: string,
  cents: number,
): Promise<Result<Receipt, ChargeError>>;

The caller cannot reach .value without first checking .ok, and once they do, TypeScript narrows error to the full ChargeError union. A switch (result.error.kind) with no default will trigger a compile error the day someone adds a new error variant and forgets to handle it. That is the entire point: failures become a thing the compiler counts.

Raw if (result.ok) branching gets noisy fast, so I add the two combinators that earn their keep — map (transform the success) and andThen (chain another fallible step, short-circuiting on the first error):

export function map<T, U, E>(
  r: Result<T, E>,
  fn: (value: T) => U,
): Result<U, E> {
  return r.ok ? ok(fn(r.value)) : r;
}
 
export function andThen<T, U, E, F>(
  r: Result<T, E>,
  fn: (value: T) => Result<U, F>,
): Result<U, E | F> {
  return r.ok ? fn(r.value) : r;
}

Note how andThen widens the error type to E | F. Chain three fallible steps and the union accumulates every way the pipeline can fail — and the compiler forces you to handle all of them at the end. That accumulation is the feature.

Step three: parse, don't trust — zod returning a Result

External input is the highest-value place for this pattern. zod (v4 in 2026) already gives you safeParse, which returns a result-shaped object. I adapt it to my Result type so validation flows through the same combinators as everything else:

import { z } from "zod";
 
const SignupSchema = z.object({
  email: z.email(),
  age: z.number().int().min(13),
});
 
type Signup = z.infer<typeof SignupSchema>;
 
function parse<T>(
  schema: z.ZodType<T>,
  input: unknown,
): Result<T, ValidationError> {
  const parsed = schema.safeParse(input);
  if (parsed.success) return ok(parsed.data);
  return err(
    new ValidationError(parsed.error.issues.map((i) => i.message)),
  );
}
 
const result = parse(SignupSchema, await req.json());
if (!result.ok) {
  return Response.json({ errors: result.error.issues }, { status: 400 });
}
// result.value is now a fully-typed Signup — no `as`, no assumptions
createUser(result.value);

The unknown from req.json() is contained at exactly one place. Past that line, you are working with a Signup, and the failure path is a typed ValidationError you have to handle, not a thrown exception you might forget about.

The real question: Result or exception?

This is where people overcorrect. Wrapping everything in Result turns your codebase into a .ok checking machine and buries genuine bugs — a null deref or an out-of-memory should crash loudly, not get politely threaded through ten function returns. My rule: Result for expected, domain-level failures; exceptions for the genuinely exceptional.

SituationUseWhy
User input fails validationResultExpected, the caller must handle it
Resource not foundResultA normal outcome, not a bug
Payment declinedResultBusiness logic branches on it
Config file missing at bootthrowCannot continue; crash early
Programmer error (assertion, invariant)throwA bug to fix, not a path to handle
Out of memory / paniclet it propagateNothing useful to do locally

The boundary that matters is the module boundary. Inside a module, throwing for control flow can be fine and ergonomic. At the public edge — the API a function exposes to the rest of the app — I return Result for anything a sane caller should anticipate. To bridge throwing code into that boundary, I wrap it once:

async function tryCatch<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)));
  }
}
 
// Stripe throws; I convert at the boundary and map to my domain error.
const charged = await tryCatch(() => stripe.charges.create({ amount, customer }));
if (!charged.ok) {
  return err(new CardDeclinedError("gateway_error"));
}

That e instanceof Error ? e : new Error(String(e)) is the one piece of unknown-narrowing you will write over and over. Centralize it once and never hand-roll it in scattered catch blocks again.

neverthrow or hand-rolled?

The 30 lines above are a working Result library. So why does neverthrow exist? Method chaining (result.map(...).andThen(...)), a ResultAsync type so you don't await then unwrap, a safeTry generator syntax that reads like Rust's ? operator, and an ESLint rule that fails the build when you forget to handle an error. For a team, that ESLint rule alone is worth the dependency — it converts "you should check this" into "CI is red."

My heuristic: a small service or a library where you want zero dependencies, hand-roll it — the code above is the whole thing. A larger app with multiple engineers, take neverthrow and turn on @typescript-eslint plus the neverthrow rule. The pattern is what matters; the library is ergonomics and enforcement on top.

What I do not recommend is the "errors as values via a tuple" Go style (const [err, val] = await thing()). It throws away the discriminated union — you lose exhaustiveness and you are back to truthiness checks on a possibly-null error. If you are going to encode failures in the type system, encode them properly.

Checklist before you ship

  • Enable strict and confirm useUnknownInCatchVariables is on (it is, under strict, since 4.4). Never disable it.
  • Every catch narrows unknown before touching the value — e instanceof Error ? e : new Error(String(e)), centralized.
  • Domain errors are subclasses of one base with a literal kind discriminant, and they pass { cause } to preserve the original.
  • Public, fallible module functions return Result<T, DomainError> — not Promise<T> with a hidden throw.
  • Validation at every system boundary (HTTP body, queue message, env vars) goes through safeParse and returns a Result.
  • Exhaustive switch on error.kind with no default, so a new error variant breaks the build until it is handled.
  • Reserve throw for invariants, boot-time misconfiguration, and truly unrecoverable states — and let those crash loudly.

The compiler is willing to track every way your code can fail. It just needs you to put failures in the return type instead of throwing them into the void. Do that, and "did I handle that error?" stops being a question you answer with grep at 2am, and becomes one the type checker answers before you merge.