The Practical Developer

TypeScript Discriminated Unions: How To Model Real Domains Without Optional Fields Everywhere

Most TypeScript domain models are a single interface with twenty optional fields, half of which are mutually exclusive. Discriminated unions are the feature that turns that into the actual shape of the data — and lets the compiler narrow precisely. Here is the pattern, applied to four real domain shapes.

A developer at a keyboard — modeling a real domain in TypeScript is exactly this kind of careful work

A typical domain interface ends up looking like this:

interface Order {
  id: string;
  status: 'pending' | 'shipped' | 'cancelled';
  trackingNumber?: string;          // shipped only
  shippedAt?: Date;                 // shipped only
  cancelReason?: string;            // cancelled only
  cancelledAt?: Date;               // cancelled only
  estimatedShipDate?: Date;         // pending only
}

Twenty optional fields, half of which are mutually exclusive. Every read has to null-check. Refactors are scary because the compiler does not know that trackingNumber is always present when status === 'shipped'. The bug is one mis-read: assigning cancelReason to a pending order and shipping it to production.

The fix is to model the domain as a discriminated union — a TypeScript feature designed exactly for “this object can be one of several shapes.” Once you reach for it, half of your ?: annotations disappear and the compiler narrows your types automatically.

The pattern in 30 seconds

A discriminated union is A | B | C where each variant has a literal field that distinguishes it. The literal field is the discriminant.

type Order =
  | { status: 'pending';   id: string; estimatedShipDate: Date; }
  | { status: 'shipped';   id: string; trackingNumber: string; shippedAt: Date; }
  | { status: 'cancelled'; id: string; cancelReason: string;  cancelledAt: Date; };

Every variant has its own required fields. No optionals. The status literal tells the compiler which branch to narrow to.

function describe(o: Order): string {
  switch (o.status) {
    case 'pending':   return `ships around ${o.estimatedShipDate.toDateString()}`;
    case 'shipped':   return `tracking ${o.trackingNumber}`;
    case 'cancelled': return `cancelled: ${o.cancelReason}`;
  }
}

In each case, the compiler knows exactly which variant o is and which fields exist. No o.trackingNumber! non-null assertions. No if (o.cancelReason) redundant checks.

If you add a fourth variant later ('on_hold'), every switch in the codebase becomes a TypeScript error until you handle it (with @typescript-eslint/switch-exhaustiveness-check enabled — see the ESLint rules post). That is a refactor superpower you cannot get from optional fields.

Pattern 1: Operation results

type Result<T> =
  | { ok: true;  value: T }
  | { ok: false; error: string };

async function fetchUser(id: string): Promise<Result<User>> {
  try {
    const user = await api.getUser(id);
    return { ok: true, value: user };
  } catch (err) {
    return { ok: false, error: (err as Error).message };
  }
}

const r = await fetchUser('42');
if (r.ok) {
  console.log(r.value.name);   // narrowed: r.value exists.
} else {
  console.log(r.error);        // narrowed: r.error exists.
}

This pattern (Rust calls it Result, Haskell calls it Either) replaces try/catch in business logic. The compiler refuses to let you read r.value without first checking r.ok. Every error is explicit.

For network code, extend with status:

type ApiResult<T> =
  | { kind: 'ok';            data: T }
  | { kind: 'error';         status: number; message: string }
  | { kind: 'network-error'; cause: unknown };

Now callers can branch differently on “the API said no” vs “we never reached the API.”

Pattern 2: Forms with conditional fields

type Address =
  | { country: 'US'; state: USState;          zip: string }
  | { country: 'CA'; province: CanadaProvince; postal: string }
  | { country: 'UK'; postcode: string }
  | { country: 'DE'; plz: string };

Each country has its own address fields. A render function:

function AddressForm({ value }: { value: Address }) {
  switch (value.country) {
    case 'US': return <USInputs state={value.state} zip={value.zip} />;
    case 'CA': return <CAInputs province={value.province} postal={value.postal} />;
    case 'UK': return <UKInputs postcode={value.postcode} />;
    case 'DE': return <DEInputs plz={value.plz} />;
  }
}

You cannot accidentally render a state field for a UK address. The compiler ensures value.state is only ever read inside the 'US' branch.

Pattern 3: WebSocket / event messages

type Event =
  | { type: 'user.joined'; userId: string; at: Date }
  | { type: 'user.left';   userId: string; at: Date }
  | { type: 'message';     userId: string; text: string; at: Date }
  | { type: 'typing';      userId: string }
  | { type: 'error';       code: string; message: string };

function handle(e: Event) {
  switch (e.type) {
    case 'message':
      console.log(`${e.userId}: ${e.text}`);
      break;
    case 'error':
      report(e.code, e.message);
      break;
    // ...
  }
}

A new event type — say 'reaction' — gets added; every handler that doesn’t handle it becomes a compile error. You cannot deploy half-handled events.

Pattern 4: State machines

type FetchState =
  | { phase: 'idle' }
  | { phase: 'loading' }
  | { phase: 'success'; data: User }
  | { phase: 'error';   error: string }
  | { phase: 'success-stale'; data: User; refreshing: true };

Components render based on phase. No isLoading && data checks. No “loading is true but data is also there.” The state machine is the type.

function UserCard({ state }: { state: FetchState }) {
  switch (state.phase) {
    case 'idle':           return null;
    case 'loading':        return <Spinner />;
    case 'success':        return <Card user={state.data} />;
    case 'success-stale':  return <Card user={state.data} stale />;
    case 'error':          return <ErrorBox message={state.error} />;
  }
}

The component cannot be in an inconsistent state because the type cannot represent one.

Tagged constructors for ergonomics

Building a discriminated-union value verbosely is annoying:

const r: Result<User> = { ok: true, value: user };

Add tagged constructors:

export const ok    = <T>(value: T): Result<T> => ({ ok: true, value });
export const err   = <T>(error: string): Result<T> => ({ ok: false, error });

const r = ok(user);
const e = err('not found');

Cleaner at call sites. Same type.

Exhaustiveness checking with never

For the cases TS does not auto-detect (e.g., a function that returns but does not switch), use assertNever:

function assertNever(x: never): never {
  throw new Error(`unhandled variant: ${JSON.stringify(x)}`);
}

function describe(o: Order): string {
  switch (o.status) {
    case 'pending':   return 'pending';
    case 'shipped':   return 'shipped';
    case 'cancelled': return 'cancelled';
    default: return assertNever(o);
  }
}

If a new variant is added without a case, o in the default is no longer never, and the call to assertNever(o) becomes a type error. Forces you to update the switch.

When NOT to use a discriminated union

If the variants share most of their fields and differ only in one or two, a single optional-field interface is fine. The line is roughly: more than two variant-specific fields per branch is when discriminated unions pay off.

Also, if the discriminant is determined by runtime (e.g., the result of an HTTP call), the union is the right model. If it is determined by the call site (the caller knows which kind they want), function overloads might be cleaner.

The takeaway

Optional-field-everywhere domain models are TypeScript’s equivalent of “primitive obsession.” They compile but they let bugs through that the compiler is fully capable of catching. Discriminated unions express the actual shape of the data — variants, with required fields per variant — and let the compiler narrow exactly when you check the discriminant.

The first time you refactor a domain object from twenty optionals to a four-variant union, half the null checks disappear and the type errors that show up are real bugs. That is the right kind of change.


A note from Yojji

The kind of domain modeling that turns a fragile interface with twenty optional fields into a small, sharp union of clear shapes is the kind of senior engineering judgment Yojji’s teams bring to client work.

Yojji is an international custom software development company founded in 2016, with teams across Europe, the US, and the UK. They specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms, and full-cycle product engineering — including the type-modeling decisions that decide whether a codebase grows safely or accumulates fragile shortcuts.