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",42ortrue. - 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 constafter a value to keep every literal exact and make everythingreadonly. - 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: stringorfunction f(n: number), instead of letting TypeScript work it out. - typeof (in a type)
- In a type position,
typeof valuemeans "the type of that value". It is different from the runtimetypeofcheck. - 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.
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.
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); // fine04as 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.
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
Predict the widening
In the third example, change
let d = "left";toconst d = "left";. Predict whether the error onmove(d)stays.It disappears: a
constkeeps the literal type"left". - 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 };. Hoverconfig.mode: it is now the union, andsetMode(config.mode)works.The annotation made the property writable but limited to two values, so
config.mode = "light"is also fine andconfig.mode = "blue"is an error. - 3
Grow the list
In the second example, change
["S", "M", "L"]to["S", "M", "L", "XL"]. The^?undertype Sizenow includesXL. Nothing else changed.
Code & diagrams
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)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
darklet 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
leftBreak 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);.
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 whyapply({ mode: "dark" })works while a stored object doesn't. - ▸
In TypeScript 5.0,
consttype parameters (function f<const T>(x: T)) applyas constbehaviour 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:
-1is 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; usenumberplus a runtime check (Topic 4.7 shows type guards) when the set is big or open.
Remember this
- 1
The problem this solves: A
stringaccepts any text, so a typo like"lft"where only"left"or"right"make sense passes the checker and fails later. So farstringmeans "any text". A literal type is narrower:"up"is the type whose only value is the textup.42is the type whose only value is the number 42.trueis the type whose only value istrue. There are three families: string literals, number literals and boolean literals (trueandfalse).bigintliterals like10nexist too. - 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 realenumkeyword. - 3
Numbers work the same way:
type Dice = 1 | 2 | 3 | 4 | 5 | 6allows only those values. Assigning7is 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 plainstringthrows away the editor's help and the compiler's checks. - 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 = 42has type42, because aconstcan never change, so its type can be exact.let count = 42has typenumber, because aletmay be reassigned to another number. The same for strings:const a = "hi"is"hi",let b = "hi"isstring. TypeScript chooses the narrowest type that is still honest. - 5
Widening also happens inside objects and arrays, even with
const.const config = { mode: "dark" }typesmodeasstring, because object properties can be changed later (theconstonly stops you re-pointingconfigitself). That surprises people when they passconfig.modeto a function that wants"dark" | "light": the error isArgument of type 'string' is not assignable to parameter of type '"dark" | "light"'. - 6
**
as const(a const assertion**) fixes this. Writing{ mode: "dark", retries: 3 } as consttells TypeScript: treat this value as a constant. Every property becomesreadonly(it can't be assigned to), every value keeps its exact literal type, and arrays becomereadonlytuples (fixed-length arrays, Phase 5).["S", "M", "L"] as consthas the typereadonly ["S", "M", "L"]. - 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];. Heretypeof sizesin a type position means "the type of the valuesizes", 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
What is a literal type and why would you use a union of them?
Why is const x = "a" typed "a" but let x = "a" typed string?
A function wants "dark" | "light", and passing config.mode fails although config was declared with const. Why, and how do you fix it?
How do you derive a union type from an array of values?
Practice
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.
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.
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 constarray 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 constor an annotation.I can derive a union type from a
readonlyarray.I can explain why literal unions can replace an enum in most code.