TypeScript Advanced #5: Discriminated unions and type guards in depth

In #3 conditional types and infer we saw the tools for distributing over unions. This post takes a step back and goes deep into how to model data of multiple shapes as a union itself.

We saw the basics of narrowing in basics #4 and reducer actions in practice #3, but that was just the start. In this post we add user-defined type guards, assertion functions, and branded types on top.

Discriminated unions revisited #

Core idea in one line:

If every member has a literal field of the same name, it’s a discriminated union.

Basic shape
type Result =
  | { ok: true; data: string }
  | { ok: false; error: string };

function handle(r: Result) {
  if (r.ok) {
    console.log(r.data);    // here r is { ok: true; data: string }
  } else {
    console.error(r.error); // here r is { ok: false; error: string }
  }
}

ok is the discriminator. It’s enough to have a value that branches cleanly here — boolean, string literal, anything. Names like kind, type, status are common, but the name doesn’t have to be one of these.

A field present on only one member also works as a discriminator #

You can branch even when the same-named field doesn’t exist on other members. Like this example.

Branch by field presence
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }
  | { kind: 'rect'; width: number; height: number };

function area(s: Shape) {
  if ('radius' in s) return Math.PI * s.radius ** 2;     // circle
  if ('side' in s) return s.side ** 2;                   // square
  return s.width * s.height;                              // rect
}

The in operator like 'radius' in s also works for narrowing. That said, explicit discriminators (the kind in this example) are usually cleaner. The in pattern fits places where we don’t get to define the shape ourselves, like external data.

Exhaustiveness check — the never pattern #

What if you add a new member to a union but forget to handle it in switch? You can have the compiler catch it.

Exhaustiveness check
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number };

function area(s: Shape): number {
  switch (s.kind) {
    case 'circle': return Math.PI * s.radius ** 2;
    case 'square': return s.side ** 2;
    default: {
      const _exhaustive: never = s;     // ✗ here if you forget a member
      return 0;
    }
  }
}

If you add 'rect' to Shape, in default s becomes { kind: 'rect'; ... }, and trying to assign that to a never variable produces a red squiggle. When a new member arrives, compilation breaks at unhandled cases, catching the bug ahead of time.

User-defined type guards — value is X #

In the basics course we saw narrowing happen with typeof/instanceof/in. You can encapsulate that into a function — that’s a user-defined type guard.

User-defined type guard
type User = { id: string; name: string };

function isUser(value: unknown): value is User {
  if (typeof value !== 'object' || value === null) return false;
  const v = value as Record<string, unknown>;
  return typeof v.id === 'string' && typeof v.name === 'string';
}

function greet(value: unknown) {
  if (isUser(value)) {
    console.log(`Hello, ${value.name}`);  // here value is User
  }
}

The return type isn’t boolean but value is User. It’s a promise: “if this function returns true, the caller should narrow value to User.”

This pattern showed up in #6 fetch and API response typing when we narrowed external data. That case was running on this principle.

The danger — you can lie #

The return of a type guard function is decided by the developer. The compiler doesn’t notice if you skip a check inside.

Lying guard — dangerous
function isUser(value: unknown): value is User {
  return true;     // compiler accepts it but it's a lie
}

const x: unknown = 42;
if (isUser(x)) {
  console.log(x.name);  // compiles. blows up at runtime as undefined.name
}

When making a type guard, the actual validation logic must match the return signature for safety. At larger scales, expressing validation and the guard together with a schema like zod is safer.

Assertion functions — asserts value is X #

A cousin of the type guard. Throws if the check fails; afterward, the type is narrowed.

Assertion function
function assertIsUser(value: unknown): asserts value is User {
  if (typeof value !== 'object' || value === null) {
    throw new Error('Not a User');
  }
  const v = value as Record<string, unknown>;
  if (typeof v.id !== 'string' || typeof v.name !== 'string') {
    throw new Error('Not a User');
  }
}

function process(value: unknown) {
  assertIsUser(value);
  console.log(value.name);   // from here value is User
}

You don’t need to wrap in an if. Throw on failure, auto-narrow on pass. Fits straight-line logic like “from this point on it must be a User.”

There’s also an asserts condition form.

asserts condition
function assert(condition: unknown, message?: string): asserts condition {
  if (!condition) throw new Error(message);
}

function getName(user: User | null) {
  assert(user !== null, 'user는 null일 수 없음');
  return user.name;     // here user is narrowed to User
}

If assert passes, the compiler treats user !== null as a fact afterward. Keeps code clean without deep if chains.

Pitfall — doesn’t work on function expressions #

Assertion functions have one constraint — they only work on functions with explicit signatures. If you put one on an arrow function with inferred typing, the narrowing won’t happen.

This doesn't work
const assert = (condition: unknown, message?: string) => {
  if (!condition) throw new Error(message);
};

function getName(user: User | null) {
  assert(user !== null);
  return user.name;     // ✗ user isn't narrowed (User | null)
}

An assertion function must be a declared function with the asserts ... signature spelled out. Worth memorizing — a common pitfall.

Branded types — making the same string a different type #

When UserId and PostId are both string, TypeScript can’t catch swapping them. They’re the same shape.

This isn't caught
type UserId = string;
type PostId = string;

function getUser(id: UserId): User { /* ... */ }

const postId: PostId = 'p_42';
getUser(postId);     // compiles — actually wrong intent

To catch this, use branded types (also called nominal typing). The core trick is to be string at runtime but a different shape at the type level.

Brand pattern
type UserId = string & { readonly __brand: 'UserId' };
type PostId = string & { readonly __brand: 'PostId' };

function getUser(id: UserId): User { /* ... */ }

const a = 'u_1' as UserId;
getUser(a);                              // OK

const b: PostId = 'p_42' as PostId;
getUser(b);                              // ✗ PostId isn't UserId

getUser('raw-string');                   // ✗ a plain string also can't pass

string & { __brand: 'UserId' } is the trick. At runtime it’s just a string, but at the type level it has a different identity via __brand. Other brands or plain strings aren’t compatible.

One step up — expressing validated data #

Branding doesn’t stop at distinguishing IDs. You can also express values that have passed validation as types.

An email that has passed validation
type Email = string & { readonly __brand: 'Email' };

function isValidEmail(s: string): s is Email {
  return /^[^@]+@[^@]+\.[^@]+$/.test(s);
}

function sendEmail(to: Email, subject: string) { /* ... */ }

const raw = '아무거나';
sendEmail(raw, '안녕');               // ✗ string is not Email

if (isValidEmail(raw)) {
  sendEmail(raw, '안녕');             // OK — only after passing the guard
}

sendEmail declares in its signature that it takes a “validated email.” Callers must pass through the guard once before they can send. Missing validation is caught at compile time. Big practical value.

One more level — modeling errors with the Result type #

A pattern that handles errors as values instead of throwing. Similar to Rust’s Result.

Result pattern
type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

async function fetchUser(id: string): Promise<Result<User>> {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) return { ok: false, error: new Error(`HTTP ${res.status}`) };
    const user = (await res.json()) as User;   // validation needed in real code
    return { ok: true, value: user };
  } catch (e) {
    return { ok: false, error: e as Error };
  }
}

const r = await fetchUser('u_1');
if (r.ok) {
  console.log(r.value.name);
} else {
  console.error(r.error);
}

The strength of discriminated unions stays. Callers must handle both branches or compilation breaks. Safer than code where throw/try-catch is unconsciously scattered.

This pattern carries a strong signal. For small functions, throw is light and enough; at shared API boundaries, the Result model is often safer.

Cases where narrowing breaks #

Finally, two common pitfalls where narrowing doesn’t hold.

1) Losing narrowing across async #

Narrowing released across await
async function f(value: string | null) {
  if (value === null) return;

  await something();
  console.log(value.length);   // here value is still string
}

This example actually works. But when other code may have changed value in between (e.g., captured by a closure), the compiler may give up the narrowing. In real code, storing the narrowed value in a separate variable is safer.

Hold the narrowed result
async function f(value: string | null) {
  if (value === null) return;
  const safe = value;          // capture the narrowed string

  await something();
  console.log(safe.length);    // safe
}

2) Losing narrowing after a method call #

After a method call
type Box = { item?: { name: string } };

function f(box: Box) {
  if (box.item === undefined) return;

  doSomething();

  console.log(box.item.name);   // compiler may suspect box.item again
}

When the compiler considers that doSomething() might have changed box, the undefined possibility may resurface. Holding the narrowed value in a variable is again the safest pattern.

Wrap-up #

What this post covered:

  • Discriminated union — branch by a same-named literal field, or with in
  • Exhaustiveness check — catch missed members with a never variable
  • User-defined type guards — value is X signature. Watch out for lying.
  • Assertion functions — asserts value is X. Only works on declared functions.
  • Branded types — string & { __brand: 'UserId' } to distinguish same shapes
  • Expressing validated data as types (the Email pattern)
  • The Result pattern — modeling errors as values
  • Where narrowing breaks — hold narrowed values in variables

In the next post (#6 Modules and .d.ts), we expand from a single module’s view and cover how to handle and extend the types of external libraries — declaration files and module augmentation.

X