Skip to content
TR

Phase 2 · Type system core · Lesson 2.1

Beginner

Unions and literal types

Say a value is one of several types, or one of a few exact values. Learn how literal types widen, how `as const` stops it, and why you can only use shared members of a union.

18 min

Real values are rarely just "a string". An HTTP method is "GET" or "POST", never "banana". An ID might be a number or a string depending on where it came from. Union types and literal types let you say exactly that, and they are the foundation for everything in this phase: narrowing, discriminated unions and most of the advanced types later on.

Union types: "one of these"

A union type is written with | and means "a value of any one of these types".

let id: string | number;
 
id = 42;      // fine
id = "a-42";  // fine
// @ts-expect-error -- Type 'boolean' is not assignable to type 'string | number'.
id = true;

Unions work anywhere a type works: parameters, return types, array elements, object properties.

function formatId(id: string | number): string {
  return `#${id}`;
}
 
const mixed: (string | number)[] = [1, "two", 3];
 
type Maybe<T> = T | null | undefined;
const name: Maybe<string> = null;

Note the parentheses in (string | number)[]: an array whose elements are either type. Without them, string | number[] means "a string, or an array of numbers". Operator precedence trips people up here.

You can only use what every member has

This is the rule that makes unions useful and annoying at first. With a union, TypeScript only lets you access members that exist on every member of the union, because it can't know which one you actually have.

function shout(value: string | number) {
  value.toString();        // fine: both string and number have toString()
  // @ts-expect-error -- Property 'toUpperCase' does not exist on type 'string | number'.
  return value.toUpperCase();
}

The same rule applies to object types. Here radius exists on only one member, so it's off-limits on the union:

type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; size: number };
type Shape = Circle | Square;
 
function describe(shape: Shape) {
  shape.kind;              // fine: every member has `kind`
  //    ^? (property) kind: "circle" | "square"
 
  // @ts-expect-error -- Property 'radius' does not exist on type 'Shape'.
  return shape.radius;
}

Notice that shape.kind is itself a union: "circle" | "square". Reading a shared property gives you the union of its types across members.

To use member-specific things you narrow the union first: check which case you have, and TypeScript refines the type inside that branch. That's the whole next lesson; here is a preview:

function shout(value: string | number) {
  if (typeof value === "string") {
    return value.toUpperCase(); // value: string here
  }
  return value.toFixed(2);      // value: number here
}

Quick check

Which line is a type error?

function take(input: string | string[]) {
  input.length;
  input.slice(0, 1);
  input.push("x");
}

Literal types: one exact value

A literal type is a type with exactly one value. "GET" is a type whose only value is the string "GET". Numbers and booleans work too.

let method: "GET" = "GET";
// @ts-expect-error -- Type '"POST"' is not assignable to type '"GET"'.
method = "POST";
 
let answer: 42 = 42;
let yes: true = true;

On their own, single literal types are rarely useful. Combined with unions they are everywhere:

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type Direction = "up" | "down" | "left" | "right";
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
 
function request(url: string, method: HttpMethod) {
  return fetch(url, { method });
}
 
request("/users", "GET");
// @ts-expect-error -- Argument of type '"get"' is not assignable to parameter of type 'HttpMethod'.
request("/users", "get");

Your editor autocompletes the four allowed values, and a lowercase typo is caught at compile time instead of turning into a confusing 405 from the server.

Fun fact: boolean itself is just the union true | false. That's why narrowing a boolean with if (flag === true) leaves false in the else branch.

Literal widening: when "GET" becomes string

When TypeScript infers a type (you didn't write one), it has to guess how specific to be. The rule is about whether the value can change.

const a = "GET";
//    ^? const a: "GET"
 
let b = "GET";
//  ^? let b: string

A const can never be reassigned, so its type can be the exact literal. A let might be reassigned later, so TypeScript widens the literal to its general type, string. Otherwise b = "POST" would be an error, which would surprise everyone.

Object properties widen

This is the one that bites people. A const object can't be reassigned, but its properties can still be mutated. So TypeScript widens every property:

const req = { url: "/users", method: "GET" };
//    ^? const req: { url: string; method: string; }
 
req.method = "PATCH"; // allowed: properties are mutable

Arrays widen too

Arrays are mutable, so element types widen and the length is not tracked:

const methods = ["GET", "POST"];
//    ^? const methods: string[]
 
const pair = [1, "one"];
//    ^? const pair: (string | number)[]

pair is not inferred as a tuple [number, string]. It's an array of either, of any length.

The classic error this causes

Put widening and literal unions together and you get one of the most common TypeScript errors in real code:

type HttpMethod = "GET" | "POST";
function send(url: string, method: HttpMethod) {}
 
const req = { url: "/users", method: "GET" };
// @ts-expect-error -- Argument of type 'string' is not assignable to parameter of type 'HttpMethod'.
send(req.url, req.method);

req.method was widened to string at the moment req was created. By the time you call send, TypeScript only knows "some string", which might be "banana".

A subtle one: widening follows the value

Even a const literal can widen later when it's copied into a let:

const c = "GET";   // type "GET", but a "widening" literal
let d = c;
//  ^? let d: string
 
const e: "GET" = "GET"; // explicitly annotated: non-widening
let f = e;
//  ^? let f: "GET"

When the literal type was only inferred, it widens on the way into a mutable location. When you wrote it, TypeScript respects your annotation. It's rarely a problem in practice, but it's a nice detail to know when an interviewer asks "why did this become string?".

Quick check

What type does TypeScript infer for config.mode?

const config = { mode: "dark", size: 12 };

Three ways to keep the literal

Given the send example above, you have three fixes. Each is useful in different places.

1. Annotate the object with a type that uses the literal union:

type HttpMethod = "GET" | "POST";
type Req = { url: string; method: HttpMethod };
 
const req: Req = { url: "/users", method: "GET" };
//    ^? const req: Req

2. as const on the value, which you'll meet properly in the next section:

const req = { url: "/users", method: "GET" } as const;
//    ^? const req: { readonly url: "/users"; readonly method: "GET"; }

3. as const on a single property, if only one field needs to stay narrow:

const req = { url: "/users", method: "GET" as const };
//    ^? const req: { url: string; method: "GET"; }

as const: freeze the inference

as const is a const assertion. It tells TypeScript: "infer the narrowest possible type, and treat everything as read-only". It does three things at once:

  1. Literals don't widen ("GET" stays "GET").
  2. Object properties become readonly.
  3. Array literals become readonly tuples with a fixed length.
const point = [10, 20] as const;
//    ^? const point: readonly [10, 20]
 
const settings = {
  theme: "dark",
  sizes: [12, 14, 16],
} as const;
// settings: { readonly theme: "dark"; readonly sizes: readonly [12, 14, 16]; }
 
// @ts-expect-error -- Cannot assign to 'theme' because it is a read-only property.
settings.theme = "light";

as const is deep: nested objects and arrays become readonly too.

String literal unions as a lightweight enum

TypeScript has an enum keyword (covered later), but a lot of teams prefer a plain union of string literals. It needs no runtime code, and values are just strings, so they serialize to JSON cleanly.

type Status = "draft" | "published" | "archived";
 
function label(status: Status): string {
  if (status === "draft") return "Draft";
  if (status === "published") return "Live";
  return "Archived"; // status: "archived" here
}
 
label("published");

The one thing a union lacks is a runtime list of the values, for a dropdown or for validation. The idiom is to write the list once with as const and derive the type from it:

const STATUSES = ["draft", "published", "archived"] as const;
//    ^? const STATUSES: readonly ["draft", "published", "archived"]
 
type Status = (typeof STATUSES)[number];
//   ^? type Status = "draft" | "published" | "archived"
 
function isStatus(value: string): value is Status {
  return (STATUSES as readonly string[]).includes(value);
}
 
const fromUrl = "published";
if (isStatus(fromUrl)) {
  console.log(`Valid status: ${fromUrl}`);
}

▶ Try it in the TypeScript Playground

Two pieces of syntax to unpack:

  • typeof STATUSES in a type position means "the type of this value": readonly ["draft", "published", "archived"].
  • [number] is an indexed access: "the type you get by indexing with any number", which is the union of all element types.

Add a new status to the array and the type updates automatically. One source of truth for both runtime and compile time. (value is Status is a type predicate; you'll see it again in the narrowing lesson. The cast in isStatus is needed because includes on a readonly tuple of literals only accepts those literals.)

Unions simplify themselves

TypeScript normalizes unions, and a few rules come up in interviews:

type A = string | "hello";
//   ^? type A = string
 
type B = "a" | "b" | "a";
//   ^? type B = "a" | "b"
 
type C = number | never;
//   ^? type C = number
  • Subtype reduction: "hello" is already a string, so string | "hello" is just string.
  • Duplicates collapse, and order doesn't matter.
  • never disappears from a union: it's the empty set, adding nothing.

The first rule has a practical cost: type Color = "red" | "blue" | string collapses to string, and you lose autocomplete for the known values. The well-known workaround is "red" | "blue" | (string & {}), which is still "any string" but isn't reduced, so the editor keeps suggesting "red" and "blue".

Spot the error

A teammate wrote this. It fails to compile. Why, and what are the fixes?

type Align = "left" | "center" | "right";
 
function setAlign(value: Align) {
  document.body.style.textAlign = value;
}
 
let align = "center";
setAlign(align);
Show the answer

let align = "center" widens to string, so setAlign(align) fails with Argument of type 'string' is not assignable to parameter of type 'Align'. Any fix that keeps the literal works:

type Align = "left" | "center" | "right";
 
function setAlign(value: Align) {
  document.body.style.textAlign = value;
}
 
// Fix 1: annotate (and you can still reassign to other valid values)
let align: Align = "center";
setAlign(align);
align = "right";
 
// Fix 2: use const if it never changes
const fixed = "center";
setAlign(fixed);

Fix 1 is usually best for a let: it documents intent and still allows reassignment to other valid values.

Recap

  • A | B means "a value of either type". You can only use members that every member of the union has, until you narrow.
  • Literal types ("GET", 42, true) have exactly one value. Unions of them model fixed sets of options. boolean is true | false.
  • Inference widens literals in mutable places: let, object properties and array elements. const keeps them.
  • Widening explains the common "string is not assignable to 'A' | 'B'" error. Fix it with an annotation or as const, not an as cast.
  • as const keeps literals, makes everything readonly (deeply), and turns arrays into tuples. It's compile-time only.
  • const X = [...] as const plus type T = (typeof X)[number] gives you a runtime list and a union type from one source.
  • Unions normalize: subtypes are absorbed (string | "a" is string), duplicates collapse, and never vanishes.

Interview cards

1 / 7