TypeScript — Literals & Unions
Literal types let you specify exact values a variable can hold — not just "string" but "hello" specifically. Combined with union types, they form the foundation of TypeScript's most powerful pattern: discriminated unions. This page covers literal types, union types, and the narrowing patterns that make them useful.
A literal type is a type that represents exactly one value. TypeScript supports string, number, and boolean literal types. They are most useful when combined into unions.
String Literals
| 1 | // Literal string type — exactly one value |
| 2 | let direction: "north" = "north"; |
| 3 | // direction = "south"; // Error: "south" is not assignable to "north" |
| 4 | |
| 5 | // String literals in unions |
| 6 | type Direction = "north" | "south" | "east" | "west"; |
| 7 | |
| 8 | let move: Direction = "north"; // OK |
| 9 | // move = "up"; // Error: "up" is not assignable to Direction |
| 10 | |
| 11 | // Practical: API endpoints |
| 12 | type ApiRoute = "/api/users" | "/api/posts" | "/api/comments"; |
| 13 | |
| 14 | function fetchEndpoint(route: ApiRoute) { |
| 15 | return fetch(route); |
| 16 | } |
| 17 | |
| 18 | fetchEndpoint("/api/users"); // OK |
| 19 | // fetchEndpoint("/api/auth"); // Error: not in the union |
| 20 | |
| 21 | // Template literal types (TS 4.1+) |
| 22 | type Method = "GET" | "POST" | "PUT" | "DELETE"; |
| 23 | type Endpoint = `/api/${string}`; |
| 24 | type ApiCall = `${Method} ${Endpoint}`; |
| 25 | |
| 26 | let call: ApiCall = "GET /api/users"; // OK |
| 27 | // let bad: ApiCall = "PATCH /api/users"; // Error |
Number Literals
| 1 | // Literal number types |
| 2 | type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6; |
| 3 | |
| 4 | let roll: DiceRoll = 3; // OK |
| 5 | // roll = 7; // Error: 7 is not assignable to DiceRoll |
| 6 | |
| 7 | // HTTP status codes |
| 8 | type SuccessCode = 200 | 201 | 204; |
| 9 | type ErrorCode = 400 | 401 | 403 | 404 | 500; |
| 10 | type StatusCode = SuccessCode | ErrorCode; |
| 11 | |
| 12 | function handleStatus(code: StatusCode) { |
| 13 | if (code >= 200 && code < 300) { |
| 14 | return "success"; |
| 15 | } |
| 16 | return "error"; |
| 17 | } |
| 18 | |
| 19 | handleStatus(200); // OK |
| 20 | handleStatus(404); // OK |
| 21 | // handleStatus(250); // Error: 250 is not assignable to StatusCode |
Boolean Literals
| 1 | // Boolean literals — true or false as types |
| 2 | type Enabled = true; |
| 3 | type Disabled = false; |
| 4 | |
| 5 | // Literal booleans in function signatures |
| 6 | function configure(options: { debug: true; verbose?: boolean }) { |
| 7 | // options.debug is always true inside this function |
| 8 | console.log("Debug mode is on"); |
| 9 | } |
| 10 | |
| 11 | // Boolean literals in unions |
| 12 | type Result = { success: true; data: string } | { success: false; error: string }; |
| 13 | |
| 14 | function handleResult(result: Result) { |
| 15 | if (result.success) { |
| 16 | // TS knows: result.data exists |
| 17 | console.log(result.data); |
| 18 | } else { |
| 19 | // TS knows: result.error exists |
| 20 | console.error(result.error); |
| 21 | } |
| 22 | } |
Union types express "this value can be one of several types." They are the bread and butter of TypeScript's type system. The | operator combines types into a union.
| 1 | // Simple union |
| 2 | let id: string | number; |
| 3 | id = "abc"; // OK |
| 4 | id = 123; // OK |
| 5 | |
| 6 | // Union of primitives |
| 7 | type Primitive = string | number | boolean | null | undefined; |
| 8 | |
| 9 | // Union with object types |
| 10 | type ApiResponse = User | Post | Comment; |
| 11 | |
| 12 | // Discriminated union (see next section for details) |
| 13 | type Shape = |
| 14 | | { kind: "circle"; radius: number } |
| 15 | | { kind: "rectangle"; width: number; height: number }; |
| 16 | |
| 17 | // Function accepting union |
| 18 | function format(value: string | number): string { |
| 19 | if (typeof value === "string") { |
| 20 | return value.trim(); |
| 21 | } |
| 22 | return value.toFixed(2); |
| 23 | } |
| 24 | |
| 25 | // Union narrowing in practice |
| 26 | type Input = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; |
| 27 | |
| 28 | function getValue(input: Input): string { |
| 29 | return input.value; // .value exists on all three |
| 30 | } |
| 31 | |
| 32 | // Union with never for exhaustive checking |
| 33 | type AllKeys = "a" | "b" | "c"; |
| 34 | type SomeKeys = "a" | "b"; |
| 35 | type Remaining = Exclude<AllKeys, SomeKeys>; // "c" |
info
A discriminated union uses a common literal property (the "discriminant") to distinguish between variants. TypeScript narrows automatically when you switch on the discriminant. This is the most important pattern in TypeScript.
| 1 | // Define variants with a common discriminant |
| 2 | type Circle = { kind: "circle"; radius: number }; |
| 3 | type Rectangle = { kind: "rectangle"; width: number; height: number }; |
| 4 | type Triangle = { kind: "triangle"; base: number; height: number }; |
| 5 | |
| 6 | type Shape = Circle | Rectangle | Triangle; |
| 7 | |
| 8 | // Narrowing with switch on discriminant |
| 9 | function area(shape: Shape): number { |
| 10 | switch (shape.kind) { |
| 11 | case "circle": |
| 12 | return Math.PI * shape.radius ** 2; |
| 13 | case "rectangle": |
| 14 | return shape.width * shape.height; |
| 15 | case "triangle": |
| 16 | return (shape.base * shape.height) / 2; |
| 17 | } |
| 18 | } |
| 19 | |
| 20 | // Pattern: success/error results |
| 21 | type Success<T> = { ok: true; value: T }; |
| 22 | type Failure = { ok: false; error: string }; |
| 23 | type Result<T> = Success<T> | Failure; |
| 24 | |
| 25 | function processResult(result: Result<number>) { |
| 26 | if (result.ok) { |
| 27 | // TS knows: result.value exists |
| 28 | console.log(result.value * 2); |
| 29 | } else { |
| 30 | // TS knows: result.error exists |
| 31 | console.error(result.error); |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | // Pattern: network request states |
| 36 | type RequestState = |
| 37 | | { status: "idle" } |
| 38 | | { status: "loading" } |
| 39 | | { status: "success"; data: User[] } |
| 40 | | { status: "error"; message: string }; |
| 41 | |
| 42 | function render(state: RequestState) { |
| 43 | switch (state.status) { |
| 44 | case "idle": |
| 45 | return "Ready"; |
| 46 | case "loading": |
| 47 | return "Loading..."; |
| 48 | case "success": |
| 49 | return `${state.data.length} users`; // TS knows: data exists |
| 50 | case "error": |
| 51 | return `Error: ${state.message}`; // TS knows: message exists |
| 52 | } |
| 53 | } |
best practice
An exhaustive check ensures you handle every variant of a discriminated union. If you add a new variant, TypeScript forces you to handle it. This prevents silent bugs when your types evolve.
| 1 | type Shape = |
| 2 | | { kind: "circle"; radius: number } |
| 3 | | { kind: "rectangle"; width: number; height: number } |
| 4 | | { kind: "triangle"; base: number; height: number }; |
| 5 | |
| 6 | // Exhaustive check using never |
| 7 | function area(shape: Shape): number { |
| 8 | switch (shape.kind) { |
| 9 | case "circle": |
| 10 | return Math.PI * shape.radius ** 2; |
| 11 | case "rectangle": |
| 12 | return shape.width * shape.height; |
| 13 | case "triangle": |
| 14 | return (shape.base * shape.height) / 2; |
| 15 | default: |
| 16 | // If you remove a case above, this line errors: |
| 17 | // "Argument of type 'Shape' is not assignable to parameter of type 'never'" |
| 18 | const _exhaustive: never = shape; |
| 19 | return _exhaustive; |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | // Adding a new variant now forces you to handle it: |
| 24 | // type Shape = Circle | Rectangle | Triangle | { kind: "pentagon"; sides: number }; |
| 25 | // → Compile error in the default case until you add a case for "pentagon" |
| 26 | |
| 27 | // Alternative: assertNever helper |
| 28 | function assertNever(x: never): never { |
| 29 | throw new Error(`Unexpected value: ${x}`); |
| 30 | } |
| 31 | |
| 32 | function area2(shape: Shape): number { |
| 33 | switch (shape.kind) { |
| 34 | case "circle": |
| 35 | return Math.PI * shape.radius ** 2; |
| 36 | case "rectangle": |
| 37 | return shape.width * shape.height; |
| 38 | case "triangle": |
| 39 | return (shape.base * shape.height) / 2; |
| 40 | default: |
| 41 | return assertNever(shape); |
| 42 | } |
| 43 | } |
Nullable types (string | null and string | undefined) are among the most common unions. TypeScript has built-in narrowing patterns for them.
| 1 | // Nullable by default — every type includes null/undefined |
| 2 | let name: string = "Alice"; |
| 3 | // name = null; // Error with strictNullChecks (default in strict mode) |
| 4 | |
| 5 | // Explicit nullable |
| 6 | let maybe: string | null = "hello"; |
| 7 | maybe = null; // OK |
| 8 | |
| 9 | // Narrowing nullable types |
| 10 | function greet(name: string | null): string { |
| 11 | if (name !== null) { |
| 12 | return `Hello, ${name}`; // TS knows: string |
| 13 | } |
| 14 | return "Hello, stranger"; |
| 15 | } |
| 16 | |
| 17 | // Optional chaining (?.) — safe property access |
| 18 | interface User { |
| 19 | name: string; |
| 20 | address?: { |
| 21 | city?: string; |
| 22 | }; |
| 23 | } |
| 24 | |
| 25 | function getCity(user: User): string | undefined { |
| 26 | return user.address?.city; // returns undefined if any part is nullish |
| 27 | } |
| 28 | |
| 29 | // Nullish coalescing (??) — default for null/undefined |
| 30 | let input: string | null = null; |
| 31 | let value = input ?? "default"; // "default" (only for null/undefined, not "") |
| 32 | |
| 33 | // Truthy narrowing |
| 34 | function process(value: string | null | undefined) { |
| 35 | if (value) { |
| 36 | // TS knows: string (removes null and undefined) |
| 37 | return value.toUpperCase(); |
| 38 | } |
| 39 | return "empty"; |
| 40 | } |
| 41 | |
| 42 | // Non-null assertion operator (!) |
| 43 | let element = document.getElementById("app")!; |
| 44 | // TS treats this as HTMLElement (not HTMLElement | null) |
warning
TypeScript narrows union types inside control flow branches. Here are all the narrowing mechanisms:
| 1 | // typeof narrowing |
| 2 | function handle(value: string | number | boolean) { |
| 3 | if (typeof value === "string") return value.toUpperCase(); |
| 4 | if (typeof value === "number") return value.toFixed(2); |
| 5 | return value ? "yes" : "no"; |
| 6 | } |
| 7 | |
| 8 | // instanceof narrowing |
| 9 | function processError(err: Error | string) { |
| 10 | if (err instanceof Error) { |
| 11 | return err.message; // Error |
| 12 | } |
| 13 | return err; // string |
| 14 | } |
| 15 | |
| 16 | // "in" operator narrowing |
| 17 | type Fish = { swim: () => void }; |
| 18 | type Bird = { fly: () => void }; |
| 19 | |
| 20 | function move(animal: Fish | Bird) { |
| 21 | if ("swim" in animal) { |
| 22 | animal.swim(); // Fish |
| 23 | } else { |
| 24 | animal.fly(); // Bird |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | // Equality narrowing |
| 29 | function check(x: string | number, y: string | boolean) { |
| 30 | if (x === y) { |
| 31 | // TS knows: both are string (only common type) |
| 32 | return x.toUpperCase(); |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | // Control flow analysis |
| 37 | function example(x: string | null) { |
| 38 | if (x === null) return; |
| 39 | // TS knows: x is string (after early return) |
| 40 | return x.toUpperCase(); |
| 41 | } |
| 42 | |
| 43 | // Discriminated union narrowing |
| 44 | type Action = |
| 45 | | { type: "increment"; amount: number } |
| 46 | | { type: "decrement"; amount: number } |
| 47 | | { type: "reset" }; |
| 48 | |
| 49 | function reducer(state: number, action: Action): number { |
| 50 | switch (action.type) { |
| 51 | case "increment": |
| 52 | return state + action.amount; |
| 53 | case "decrement": |
| 54 | return state - action.amount; |
| 55 | case "reset": |
| 56 | return 0; |
| 57 | } |
| 58 | } |
info