Kodokon kodokon.com

Literal types and discriminated unions

Model your application's states with unions the compiler checks on your behalf.

8 min · 3 questions

Open this lesson in Kodokon

A literal type restricts a value to exact content: 'info' rather than string. It's the foundation of APIs that are impossible to misuse: the compiler rejects any value outside the list, and your editor offers autocompletion.

TYPESCRIPT
type Level = "info" | "warn" | "error";

function log(level: Level, message: string): void {
  console.log(`[${level}] ${message}`);
}

log("info", "Server started");
// log("fatal", "Boom"); -> rejected by the compiler
A union of literals locks down the allowed values

The next step is combining object types that share a common literal property, called the discriminant. Each state of a network call, for example, carries only the data that belongs to it: accessing data while loading is simply impossible.

TYPESCRIPT
type ApiState =
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; message: string };

function render(state: ApiState): string {
  switch (state.status) {
    case "loading":
      return "Loading...";
    case "success":
      return state.data.join(", ");
    case "error":
      return state.message;
  }
}
A union discriminated by the status property

In each case, TypeScript narrows the type: after case 'error', state.message exists and state.data does not. This mechanism, called narrowing, replaces fragile manual checks. Add an exhaustiveness guard to lock the whole thing down.

TYPESCRIPT
function assertNever(value: never): never {
  throw new Error("Unhandled case: " + String(value));
}

function label(state: ApiState): string {
  switch (state.status) {
    case "loading": return "In progress";
    case "success": return "Done";
    case "error": return "Failed";
    default: return assertNever(state);
  }
}
Exhaustiveness guard with the never type

Knowledge check

Make sure you remember the key points of this lesson.

  1. In a discriminated union, what is the role of the shared property such as status?
    • Improving runtime performance
    • Letting the compiler narrow the type in each branch
    • Making the property required in the received JSON
    • Preventing duplicate properties
  2. With type Level = 'info' | 'warn', which call to log(level, msg) compiles?
    • log('debug', msg)
    • log('info', msg)
    • log(someString, msg) where someString is of type string
  3. What is the purpose of an assertNever(value: never) function in a switch's default branch?
    • Logging errors in production
    • Turning a forgotten case into a compile error
    • Converting the value to never
    • Terminating the program faster