Back to journal
·4 min readTypeScriptFrontendJavaScriptCode QualityTIL

TIL: Why I Stopped Trusting switch Statements for TypeScript Unions

I always assumed a switch statement over a TypeScript union was safe. Then I realized adding a new union value could silently fall through to the default case without a single compiler error. Here's why I've started looking at ts-pattern.

ShareCopy failed

A few days ago, I came across a LinkedIn post that made me stop for a second.

At first I thought, "Surely TypeScript already protects me from this."

It turns out it doesn't.

Imagine a simple union:

type Status = 'idle' | 'loading' | 'error';

Most of us would render it with a switch:

switch (status) {
  case 'idle':
    return 'Idle';

  case 'loading':
    return 'Loading...';

  case 'error':
    return 'Error';

  default:
    return '';
}

Nothing unusual here.

Now imagine a few months later someone adds a new status.

type Status =
  | 'idle'
  | 'loading'
  | 'error'
  | 'success';

The code still compiles.

The tests might even pass.

But the UI?

Instead of showing "Done", it quietly falls into the default branch.

Maybe it renders nothing.

Maybe it returns null.

Maybe users only discover it after deployment.

That's the dangerous part.

The default case makes the compiler believe you've already handled every possible scenario.

The traditional solution

A lot of TypeScript developers solve this with an assertNever() helper.

function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${value}`);
}

switch (status) {
  case 'idle':
    return 'Idle';

  case 'loading':
    return 'Loading...';

  case 'error':
    return 'Error';

  case 'success':
    return 'Done';

  default:
    return assertNever(status);
}

Now, if someone adds another union member, TypeScript complains because status is no longer never.

It's a neat solution.

I've used similar helpers before.

Then I discovered ts-pattern

The LinkedIn post introduced me to ts-pattern.

Instead of writing a switch, you write a pattern match.

import { match } from 'ts-pattern';

return match(status)
  .with('idle', () => 'Idle')
  .with('loading', () => 'Loading...')
  .with('error', () => 'Error')
  .with('success', () => 'Done')
  .exhaustive();

The interesting part isn't the syntax.

It's .exhaustive().

If another status gets added later:

type Status =
  | 'idle'
  | 'loading'
  | 'error'
  | 'success'
  | 'retrying';

The project no longer builds until you explicitly handle "retrying".

No forgotten cases.

No silent defaults.

No surprises in production.

It goes beyond strings

What impressed me even more is that ts-pattern isn't limited to simple unions.

It can match nested objects:

match(response)
  .with({ success: true }, () => ...)
  .with({ success: false }, () => ...)
  .exhaustive();

Arrays.

Tuples.

Optional properties.

Custom guards.

Even deeply nested data structures.

That's something a regular switch simply can't do elegantly.

Will I start using it everywhere?

Probably not.

For tiny components, a normal switch plus assertNever() is still perfectly reasonable.

Adding another dependency just to replace a three-case switch probably isn't worth it.

But for larger applications with complex state objects, reducers, or API responses, I can definitely see the value.

The biggest takeaway for me wasn't actually the library.

It was realizing how easily a seemingly "safe" switch statement can become unsafe the moment a default case hides missing branches.

Sometimes the most dangerous bugs aren't the ones that crash your application.

They're the ones that compile successfully.

ShareCopy failed