TypeScript unions and discriminating unions

  • typescript
Railway tracks split at a switch, where a boxy train and a cylindrical train each glide onto the one branch and tunnel shaped to match them.

A union type says a value is one of several shapes. That is exactly how application state works: a request is loading, or it failed, or it has data. So I reach for unions constantly. The catch is that a bare union only lets you touch the fields every member shares. This is how one literal field fixes that and makes the compiler narrow the type for you.

A union only exposes its shared fields

Model a user-loading state as three interfaces, then a union over them:

interface UserLoadFailed {
  statusCode: number;
  message: string;
}
interface UserLoading {
  statusCode: number;
  isLoading: boolean;
}
interface UserLoaded {
  statusCode: number;
  data: User;
}

type UserState = UserLoadFailed | UserLoading | UserLoaded;

Now consume it:

declare function getUserState(): UserState;

const user = getUserState();

user.statusCode; // fine: every member has statusCode
user.data;       // error: only UserLoaded has data

statusCode is reachable because it lives in all three members, and that intersection is the only thing TypeScript lets you touch on the bare union. Reach for data and the compiler stops you: it can’t prove you aren’t holding a UserLoadFailed.

Add a literal field to discriminate

Give every member a status field typed as a string literal. That one field is the discriminant:

interface UserLoadFailed {
  status: "failed";
  statusCode: number;
  message: string;
}
interface UserLoading {
  status: "loading";
}
interface UserLoaded {
  status: "loaded";
  data: User;
}

type UserState = UserLoadFailed | UserLoading | UserLoaded;

Check that field and TypeScript narrows the union to the one member that matches:

function render(user: UserState): string {
  switch (user.status) {
    case "failed":
      return `error ${user.statusCode}: ${user.message}`;
    case "loading":
      return "loading…";
    case "loaded":
      return user.data.name; // data is reachable: narrowed to UserLoaded
    default:
      return assertNever(user); // compile error if a state goes unhandled
  }
}

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

Inside each case, user is the specific member, so its unique fields are reachable and type-safe. The default branch is where exhaustiveness pays off: assertNever accepts a never, so if you add a fourth state and forget a case, user is no longer never there and the build fails. The states become impossible to drop silently.

The TypeScript handbook goes deeper on unions and intersections.