Effective Error Handling in TypeScript: Discriminated Unions Over Thrown Exceptions

#typescript error handling
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

A user submits a form, the API returns 500, and the stack trace in Sentry points at a line three files away from anything that looks related. Somewhere between the route handler and the database a function threw, and nothing in any type signature warned that it could.

That gap is the practical weakness in TypeScript error handling as most teams practice it. TypeScript describes the value a function returns. It says nothing about what the function throws, and there is no throws clause you can add. A function annotated Promise<User> might hand back a user, or it might take down the request. To the compiler those are the same signature, and a caller that forgets the second case compiles cleanly.

Java answered this with checked exceptions, and plenty of people who lived through them would rather not repeat the experience. The approach we keep coming back to on TypeScript projects is older and less ceremonial: put failure in the return type.

Why TypeScript error handling leaks through the type system

Exceptions are control flow that bypasses the signature. That is useful when something has genuinely gone wrong, such as a lost database connection or a bug that produced an impossible state. It is a poor fit for failures you already know about and plan to handle: a record that does not exist, a user without the right role, input that fails validation.

Those are not exceptional. They are ordinary outcomes of the operation, and pretending otherwise costs you two things. The compiler cannot tell you that a call site ignores them, and catch gives you unknown, so you end up writing instanceof chains that no exhaustiveness check ever verifies.

A Result type in nine lines

Everything below is built from one declaration:

type Result<T, E> =
  | { ok: true; value: T }
  | { ok: false; 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 });

ok is the discriminant. Because its type is the literal true or false rather than boolean, TypeScript narrows the whole object once you test it:

const result = await findUser(id);

if (!result.ok) {
  // result.value does not exist here, and the compiler knows it
  return renderError(result.error);
}

// result.value is User, no cast, no optional chaining
return renderProfile(result.value);

The narrowing is what does the work. Reaching result.value requires passing through the failure branch first, and there is no way to reach it otherwise. No linter, no code review checklist, no convention that gets forgotten under deadline pressure. This is also one of the arguments for turning on strict mode across a TypeScript codebase, since narrowing is far less useful when strictNullChecks is off.

Tagged error kinds and exhaustive switches

An error type of Error or string throws away the information that made this worth doing. Give each failure category its own tag:

type ValidationError = { kind: 'validation'; field: string; message: string };
type NotFoundError = { kind: 'not_found'; resource: string; id: string };
type PermissionError = { kind: 'permission'; requiredRole: string };

type AppError = ValidationError | NotFoundError | PermissionError;

Now the mapping from domain failure to HTTP response is a switch the compiler checks:

function toStatus(error: AppError): number {
  switch (error.kind) {
    case 'validation':
      return 422;
    case 'not_found':
      return 404;
    case 'permission':
      return 403;
    default: {
      const unreachable: never = error;
      return unreachable;
    }
  }
}

The never assignment in the default branch is the part that earns its keep. Add a fourth error kind six months from now and this function stops compiling until someone decides what status code it deserves. With instanceof chains in a catch block, the same change ships silently and surfaces as a 500 in production.

Each error carries structured fields rather than a formatted string, so the API layer can serialize field into a form error and the log line can index on resource without parsing prose.

Propagating without rethrowing

Passing a failure up the stack is a return statement:

async function publishArticle(
  id: string,
  actor: Actor,
): Promise<Result<Article, AppError>> {
  const article = await findArticle(id);
  if (!article.ok) return article;

  const allowed = canPublish(actor, article.value);
  if (!allowed.ok) return allowed;

  return ok(await markPublished(article.value));
}

return article type checks because a Result<Article, NotFoundError> in its failure state is assignable to Result<Article, AppError>. When a helper returns an error type that is not part of your union, map it at the call site instead of widening AppError until it means nothing.

Three extra lines per call is the honest cost. In exchange, reading the function tells you every way it can fail, in order, without opening the files it calls.

The boundary where Result turns back into an exception

Frameworks catch exceptions, not return values. Next.js renders error.tsx when a Server Component throws, and Symfony runs its exception listeners when a controller throws. Fighting that is not worth it. Convert at the edge:

export async function POST(request: Request) {
  const body = await request.json();
  const result = await publishArticle(body.id, await currentActor());

  if (result.ok) {
    return Response.json(result.value, { status: 200 });
  }

  return Response.json({ error: result.error }, { status: toStatus(result.error) });
}

Route handlers return the error as data, because a 404 is a legitimate response and not a crash. Server Components that hit an unrecoverable failure should throw so the nearest error boundary takes over. The rule we apply: everything below the framework boundary returns Result, and exactly one layer decides whether a given failure becomes a response body or an exception.

Teams working across PHP and TypeScript will recognize the shape. A Symfony exception listener does the same translation from domain failure to HTTP response, just in the other direction.

Zod already does this

safeParse returns a discriminated union with a success flag, which is the same pattern under a different name. Adapting it costs a few lines:

function parse<T>(schema: z.ZodType<T>, input: unknown): Result<T, ValidationError> {
  const parsed = schema.safeParse(input);
  if (parsed.success) return ok(parsed.data);

  const issue = parsed.error.issues[0];
  return err({
    kind: 'validation',
    field: issue.path.join('.'),
    message: issue.message,
  });
}

Use safeParse rather than parse anywhere the input comes from outside your system. A request body that fails validation is an expected event on any public endpoint, and it should not travel as an exception.

Migrating a try/catch codebase without a rewrite

Converting every function at once is how this initiative dies in review. Start at the seams instead.

Wrap third-party clients that throw, so the throwing stops at your boundary:

async function fetchInvoice(id: string): Promise<Result<Invoice, AppError>> {
  try {
    return ok(await billing.invoices.retrieve(id));
  } catch (cause) {
    if (isNotFound(cause)) {
      return err({ kind: 'not_found', resource: 'invoice', id });
    }
    throw cause;
  }
}

That throw cause is deliberate. Failures you did not anticipate should keep propagating to your error tracker. Result is for outcomes you have decided to handle, and using it for everything turns genuine bugs into quietly swallowed return values.

From there, convert one module at a time, starting with whichever one produces the most incident tickets. Existing callers keep their try/catch until you reach them. The two styles coexist without trouble because a function returning Result never throws for expected failures, and a function that throws is simply not converted yet.

What it costs

The verbosity is real. Every call gains a guard, and deeply nested call chains accumulate them. Libraries like neverthrow and fp-ts offer map and andThen combinators that flatten the chaining, at the cost of asking every reviewer to read functional style fluently. On mixed-seniority teams we usually keep the hand-rolled version, because a plain if statement needs no onboarding.

You also lose automatic stack traces. An error object built with a literal does not know where it came from, so attach a cause field or capture context deliberately when a failure crosses a service boundary.

Common questions

Should every function return a Result?

No. Pure functions that cannot fail should return their value. Reserve Result for operations with a real failure mode you expect to handle, such as anything touching the network, the database, the filesystem, or unvalidated input.

Does this replace my error monitoring?

It changes what reaches it. Expected failures become data and stop appearing in Sentry as exceptions, which usually cuts noise considerably. Unexpected failures still throw and still get reported, which is what you want the alerting to be about.

Do I need neverthrow or fp-ts?

Not to start. The nine-line type gives you the compiler guarantee, which is the whole point. Adopt a library when the manual guards genuinely hurt, and make it a team decision rather than one developer introducing a new paradigm in a pull request.

How does this work with TanStack Query?

Query treats a rejected promise as an error state, so a Result returned from a query function arrives as successful data with a failure inside it. Either unwrap and throw inside the query function, or check result.ok in the component. Pick one and apply it everywhere, because mixing the two produces components that handle the same failure in two places.

Where this fits

This pattern pays off on codebases with real domain logic and several failure modes per operation, particularly when a team has been burned by a production incident that a type check would have caught. On a small CRUD application it is overhead.

If you are weighing a change like this against everything else competing for the sprint, that tradeoff is the kind of question our code quality consulting work exists to answer, and error handling architecture is something we set early in custom software development projects rather than retrofitting later.

Send the specifics to hello@wolf-tech.io if you want a second opinion on a codebase, or read more about how we work at wolf-tech.io.