Command Palette

Search for a command to run...

Hectal
PHASE 4Beginner ~26 min· topic 2 of 7

Topic 4.2

Literal Types

In one line

A literal type is a type with exactly one value, like "up" or 42. Joined in a union, literals describe a small fixed set of allowed values, and as const keeps values from widening back to string or number.

Think of it like this

A traffic light has exactly three colours. A sign that says "colour" would allow "purple"; a sign that says "red, yellow or green" doesn't. A literal union is the second sign: instead of "any text", you say "one of these exact texts". The compiler then refuses anything else, and the editor lists the choices for you.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Literal type
A type that has exactly one value, like "up", 42 or true.
Literal union
Several literal types joined with |, meaning "exactly one of these values". It plays the role of an enum.
Widening
TypeScript choosing the general type (string, number) instead of the exact literal, because the value might change.
Const assertion
Writing as const after a value to keep every literal exact and make everything readonly.
readonly
A property that can be read but not assigned to. TypeScript refuses writes to it.
Annotation
A type you write yourself after a name, like let x: string or function f(n: number), instead of letting TypeScript work it out.
typeof (in a type)
In a type position, typeof value means "the type of that value". It is different from the runtime typeof check.
Enum
A fixed list of named choices. A literal union gives you the same safety without special syntax.

Step by step

01From "any text" to "one of these"

A function that moves a character takes a direction. If the parameter is a string, a typo like "lef" gets through, and the bug shows up when the character refuses to move.

Write the four allowed values as a union. Now "lef" is an error on the line where you wrote it, and the editor offers the four choices as you type the quote.

move.tswhole filets
type Direction = "up" | "down" | "left" | "right";

function move(dir: Direction) {
  console.log("moving", dir);
}
move("left");
move("lef"); // error TS2345: Argument of type '"lef"' is not assignable to parameter of type 'Direction'.

02const keeps it exact, let widens

const flag = true has type true. let flag = true has type boolean. The compiler asks one question: could this variable hold something else later? A const could not, a let could.

This matters for calls. const e = "left" can be passed to move, because its type is "left". let d = "left" cannot, because its type is string.

03Objects widen their properties

Properties of a normal object can be reassigned, so TypeScript widens them: { mode: "dark" } has mode: string. A function wanting "dark" | "light" rejects it.

Three fixes, from most to least common: add as const; annotate the variable with a type that lists the literals; or write the object directly in the call, where the expected type guides it.

config.tswhole filets
type Mode = "dark" | "light";
const a = { mode: "dark" } as const;          // mode: "dark" (readonly)
const b: { mode: Mode } = { mode: "dark" };    // annotation keeps the choice
function apply(m: Mode) { console.log(m); }
apply(a.mode);                                  // fine
apply(b.mode);                                  // fine

04as const on arrays gives a fixed list

A plain array literal is string[]: any number of strings. With as const it becomes readonly ["S", "M", "L"]: exactly three items, in this order, never changed.

Reading any index of that gives "S" | "M" | "L". That is how you derive a union type from a list of values, with one place to edit.

as const on arrays gives a fixed listdiagram
Rendering diagram…

05The widening rule in one picture

Think of a literal as the most exact answer and the primitive as the safe answer. TypeScript starts exact and relaxes to the primitive when the value could change. const variables, as const values and annotated literal unions stay exact; let variables and object properties relax.

Try it yourself

  1. 1

    Predict the widening

    In the third example, change let d = "left"; to const d = "left";. Predict whether the error on move(d) stays.

    It disappears: a const keeps the literal type "left".

  2. 2

    Annotate instead of as const

    In the second example, replace the first line with const config: { mode: "dark" | "light"; retries: number } = { mode: "dark", retries: 3 };. Hover config.mode: it is now the union, and setMode(config.mode) works.

    The annotation made the property writable but limited to two values, so config.mode = "light" is also fine and config.mode = "blue" is an error.

  3. 3

    Grow the list

    In the second example, change ["S", "M", "L"] to ["S", "M", "L", "XL"]. The ^? under type Size now includes XL. Nothing else changed.

Code & diagrams

Literal unions: the compiler knows the allowed values

The first error lists the allowed values. answer is the literal 42 because it is a const; count is plain number because it is a let. Nothing is printed: types only.

Console

(nothing was printed)
Object properties widen; as const keeps them exact

config.mode is a plain string, so it would be rejected by setMode (Break it below). frozen keeps the exact values and is readonly: the last line is an error, though at runtime the assignment still works because as const only exists in the type system.

Console

(nothing was printed)

Expected console output

dark
const versus let when calling a function

let c = a widens: a is the literal "hi", but c is a let, so its type is string. d is a string, so move(d) is rejected, although at runtime it prints left. e is the literal "left", so move(e) is accepted.

Console

(nothing was printed)

Expected console output

left
left

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Pass the widened property

In the second example, replace setMode(frozen.mode); with setMode(config.mode);.

terminal
$ Types panel
── what you'll see ──
index.ts(12,9): error TS2345: Argument of type 'string' is not assignable to parameter of type '"dark" | "light"'.

Myth vs fact

Myth

const objects have literal property types.

Fact

const only stops you from re-pointing the variable. The properties inside are mutable, so they are widened to string or number unless you use as const.

Myth

as const makes the value read-only at runtime.

Fact

It is erased like every type. It changes only what TypeScript believes. Use Object.freeze if you need runtime protection.

Myth

Literal unions are second-class compared to enums.

Fact

They give the same checking, produce no extra JavaScript, work with plain JSON data and are the common choice in modern TypeScript. Phase 5 weighs the two honestly.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    TypeScript tracks a fresh literal type (the exact type of a literal expression) and a regular one. Widening happens when a fresh literal flows into a mutable location (a let, a property, an array element) with no annotation. Contextual typing from an annotation or a parameter type cancels widening, which is why apply({ mode: "dark" }) works while a stored object doesn't.

  • ▸

    In TypeScript 5.0, const type parameters (function f<const T>(x: T)) apply as const behaviour to inference, covered in the generics phase. satisfies (TypeScript 4.9) checks a value against a type while keeping its exact literal types, which is often better than annotating.

  • ▸

    Number literals have limits: -1 is a literal type, but there is no literal type for a range like "any integer from 1 to 6" besides listing them. Large number unions get slow; use number plus a runtime check (Topic 4.7 shows type guards) when the set is big or open.

Remember this

  1. 1

    The problem this solves: A string accepts any text, so a typo like "lft" where only "left" or "right" make sense passes the checker and fails later. So far string means "any text". A literal type is narrower: "up" is the type whose only value is the text up. 42 is the type whose only value is the number 42. true is the type whose only value is true. There are three families: string literals, number literals and boolean literals (true and false). bigint literals like 10n exist too.

  2. 2

    On its own a literal type is not very useful (a variable that can only ever hold "up"). Joined with | it becomes powerful: "up" | "down" | "left" | "right" is "exactly one of these four". A literal union works like an enum (a fixed list of named choices) with no extra syntax and no extra JavaScript: the values are just the plain strings. Phase 5 compares it with the real enum keyword.

  3. 3

    Numbers work the same way: type Dice = 1 | 2 | 3 | 4 | 5 | 6 allows only those values. Assigning 7 is an error: Type '7' is not assignable to type 'Dice'. Literal unions are also used for option bags ("asc" | "desc"), HTTP methods ("GET" | "POST"), event names and configuration modes. Typing them as plain string throws away the editor's help and the compiler's checks.

  4. 4

    Widening is the key rule. A widened type is the general type TypeScript picks when you didn't ask for a literal. const answer = 42 has type 42, because a const can never change, so its type can be exact. let count = 42 has type number, because a let may be reassigned to another number. The same for strings: const a = "hi" is "hi", let b = "hi" is string. TypeScript chooses the narrowest type that is still honest.

  5. 5

    Widening also happens inside objects and arrays, even with const. const config = { mode: "dark" } types mode as string, because object properties can be changed later (the const only stops you re-pointing config itself). That surprises people when they pass config.mode to a function that wants "dark" | "light": the error is Argument of type 'string' is not assignable to parameter of type '"dark" | "light"'.

  6. 6

    **as const (a const assertion**) fixes this. Writing { mode: "dark", retries: 3 } as const tells TypeScript: treat this value as a constant. Every property becomes readonly (it can't be assigned to), every value keeps its exact literal type, and arrays become readonly tuples (fixed-length arrays, Phase 5). ["S", "M", "L"] as const has the type readonly ["S", "M", "L"].

  7. 7

    A popular pattern builds the union from the value, so there is one source of truth: const sizes = ["S", "M", "L"] as const; type Size = (typeof sizes)[number];. Here typeof sizes in a type position means "the type of the value sizes", and [number] means "the type you get when you read any index". The result is "S" | "M" | "L". Add a size to the array and the type updates by itself.

Explain it without notes

01

What is a literal type and why would you use a union of them?

02

Why is const x = "a" typed "a" but let x = "a" typed string?

03

A function wants "dark" | "light", and passing config.mode fails although config was declared with const. Why, and how do you fix it?

04

How do you derive a union type from an array of values?

Practice

01

Write a type Status for the strings "todo", "doing" and "done", and a function label(s: Status) that returns the status in capital letters. Show one correct call and one that tsc rejects.

02

Given const ROLES = ["admin", "editor", "viewer"] as const;, derive the type Role and write function isRole(x: string): x is Role using includes. If it doesn't compile, explain what the error says.

03

Fix this code without using as on the call: const req = { method: "GET", url: "/" }; function send(method: "GET" | "POST", url: string) {} send(req.method, req.url);

Trade-offs

  • ↔

    Literal unions are checked only at compile time. Data from outside (a JSON response, a URL) can hold any string, so check it at the edge before trusting the union (Topic 4.7, and runtime validation later in the course).

  • ↔

    Writing the same literals in several places invites drift. Derive the type from one as const array when the values are also needed at runtime; keep a plain union when they are only a type.

Done when you can

  • I can write a union of string and number literals.

  • I can predict whether a variable keeps a literal type or widens.

  • I can fix a widened object property with as const or an annotation.

  • I can derive a union type from a readonly array.

  • I can explain why literal unions can replace an enum in most code.