TypeScript Literal Types
A literal type is a type that represents one exact value instead of a whole category of values. Where string means “any possible string,” the literal type "up" means “the string up and nothing else.” Literal types are the foundation of some of TypeScript’s most useful patterns: restricting a parameter to a fixed set of allowed values, building discriminated unions, and creating self-documenting APIs that reject typos at compile time instead of failing at runtime.
Overview: How Literal Types Work
In plain JavaScript, the value "up" is just a string. TypeScript’s type system, however, can treat that same value as its own miniature type. A literal type is a subtype of its base primitive type: the type "up" is a more specific version of string, the type 42 is a more specific version of number, and the type true is a more specific version of boolean. Any value of a literal type is assignable to the wider primitive type, but not the other way around — you can pass "up" anywhere a string is expected, but you cannot pass an arbitrary string anywhere a "up" is expected.
On their own, literal types are rarely useful. Their real power shows up when you combine several of them into a union, using the | operator. A union of string literals like "up" | "down" | "left" | "right" behaves like a lightweight, type-safe enum: the compiler will only accept one of those four exact strings, and will flag anything else — including typos like "uup" — as an error. This is one of the most common and idiomatic uses of literal types in real TypeScript code, and it usually replaces what other languages would solve with an enum.
TypeScript supports literal types for three primitives: string literals ("success"), numeric literals (200), and boolean literals (true or false). There is no separate literal type for null or undefined because those values already only have one possible form. Object and array literals are handled differently — by default their properties are widened to their base types, which is discussed under “Under the Hood” below.
Syntax
let variableName: "literalValue" | "anotherValue";
let numberVar: 1 | 2 | 3;
let boolVar: true;
type AliasName = "literalValue" | "anotherValue";
| Form | Example | Meaning |
|---|---|---|
| String literal | "dark" |
Only the exact string dark |
| Numeric literal | 404 |
Only the exact number 404 |
| Boolean literal | true |
Only the exact boolean true |
| Literal union | "low" | "medium" | "high" |
Any one of the listed exact values |
as const |
{ mode: "dark" } as const |
Freezes inferred types down to their literal form |
A union of literal types is almost always given a name with type so it can be reused across functions and variables, rather than repeating the same list of literals everywhere.
Examples
Example 1: A basic literal union
let direction: "up" | "down" | "left" | "right";
direction = "up";
console.log(direction);
function move(dir: "up" | "down" | "left" | "right"): string {
return `Moving ${dir}`;
}
console.log(move("left"));
Output:
up
Moving left
The variable direction can only ever hold one of the four listed strings. If you tried direction = "upward", tsc would reject it immediately, long before the code ever runs. The move function repeats the same union as a parameter type, so it rejects any caller that passes something outside the four allowed directions.
Example 2: Numeric literals modeling a fixed range
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
function describeRoll(value: DiceRoll): string {
if (value === 6) {
return "You rolled the max!";
}
return `You rolled a ${value}`;
}
const roll: DiceRoll = 6;
console.log(describeRoll(roll));
console.log(describeRoll(3));
Output:
You rolled the max!
You rolled a 3
Here DiceRoll is a named alias for a union of six numeric literals. Because value is narrowed to exactly 1 | 2 | 3 | 4 | 5 | 6, the compiler knows value === 6 is a meaningful, reachable comparison — and it would flag a comparison like value === 7 as pointless, since 7 is not part of the type.
Example 3: Literal types, widening, and as const
function setStatus(status: "success" | "error" | "loading") {
console.log(`Status: ${status}`);
}
// let currentStatus = "loading" would be widened to string,
// so it could NOT be passed to setStatus directly.
const fixedStatus = "loading"; // const infers the literal type "loading"
setStatus(fixedStatus);
const config = {
mode: "dark",
} as const;
console.log(config.mode);
Output:
Status: loading
dark
Because fixedStatus is declared with const, TypeScript infers its narrowest possible type — the literal type "loading" — rather than the wider string. The config object goes one step further with as const, which recursively freezes every property to its literal type and makes the whole object readonly, so config.mode is typed as exactly "dark" rather than string.
Under the Hood
When the compiler sees a literal value in your source, it has to decide how specific a type to infer for it. This decision is governed by a process called widening:
- With
letorvar, TypeScript assumes the variable might be reassigned later, so it widens the literal to its base primitive type.let x = "red"givesxthe typestring, not"red". - With
const, the variable can never be reassigned, so TypeScript keeps the narrow literal type.const x = "red"givesxthe type"red". - Inside object literals, properties are widened even when the object is declared with
const, because object properties remain mutable by default.const obj = { mode: "dark" }infersobj.modeasstring, sinceobj.mode = "light"is legal JavaScript. - The
as constassertion overrides this default: it tells the compiler “treat every value in this expression as its most specific literal type, and make the whole structurereadonly.” This is why{ mode: "dark" } as constgivesmodethe type"dark"instead ofstring.
Structurally, TypeScript checks assignability by comparing the shape and range of types, not by name. A literal type like "loading" is a subtype of string, so it is assignable wherever string is expected; the reverse assignment fails because string includes infinitely many values that are not "loading".
It is also important to remember that, like all TypeScript types, literal types are erased at compile time. The compiled JavaScript output has no notion of "up" | "down" or 1 | 2 | 3 — those unions exist purely to help the compiler catch mistakes while you write the code. At runtime, direction is just a plain JavaScript string, and there is no type check happening when the program executes; all literal-type checking happens once, during compilation.
Common Mistakes
Mistake 1: Letting let widen a literal you meant to keep narrow
function paint(color: "red" | "green" | "blue") {
console.log(color);
}
let selected = "red"; // inferred as string, not "red"
paint(selected); // Error: Argument of type 'string' is not
// assignable to parameter of type
// '"red" | "green" | "blue"'.
Because selected was declared with let and no explicit type annotation, TypeScript widens it to string the moment it’s assigned "red". A bare string is not assignable to the narrower union "red" | "green" | "blue", so tsc reports an error at the call site. Fix it by either using const (if the variable never needs reassignment) or giving let an explicit literal-union type annotation:
function paint(color: "red" | "green" | "blue") {
console.log(color);
}
const selected: "red" | "green" | "blue" = "red";
paint(selected);
Output:
red
Mistake 2: Forgetting as const on object literals
interface Config {
mode: "light" | "dark";
}
function applyConfig(config: Config) {
console.log(config.mode);
}
const settings = {
mode: "dark", // inferred as string, not "dark"
};
applyConfig(settings); // Error: Types of property 'mode' are
// incompatible. Type 'string' is not
// assignable to type '"light" | "dark"'.
Even though settings itself is a const, its mode property is widened to string because object properties are mutable unless told otherwise. Passing settings to a function expecting the narrower Config interface fails. The fix is to freeze the object’s literal types with as const, or to annotate the object’s type explicitly:
interface Config {
mode: "light" | "dark";
}
function applyConfig(config: Config) {
console.log(config.mode);
}
const settings = {
mode: "dark",
} as const;
applyConfig(settings);
Output:
dark
Best Practices
- Prefer a named literal union type (
type Status = "idle" | "loading" | "error") over a plainstringparameter whenever a value has a small, fixed set of valid options. - Reuse the same type alias across every function and variable that represents the same concept, instead of retyping the union of literals each time — it keeps the allowed values in exactly one place.
- Use
constinstead ofletwhenever a variable’s value should stay narrow, sinceletsilently widens literal inference. - Reach for
as constwhen you need an object or array literal’s properties to keep their exact literal types, especially for configuration objects, route tables, and lookup maps. - Prefer literal-type unions over TypeScript’s
enumfor simple fixed sets of string values — they compile away completely, need no import, and work naturally with plain string values from JSON or APIs. - When a union of literals grows large or needs to be reused as both a type and a runtime array (for example, to populate a dropdown), define the array first with
as constand derive the type from it withtypeofand indexed access, rather than maintaining two separate lists by hand.
Practice Exercises
- Exercise 1: Define a type alias
TrafficLightthat is a union of the literal strings"red","yellow", and"green". Write a functionnextLightthat takes aTrafficLightand returns the next color in the cycle red → green → yellow → red. - Exercise 2: Create a
constobject literal representing an HTTP response with astatusproperty that should be exactly200,404, or500. Useas constso the property keeps its literal numeric type, then write a function that only accepts objects with that literal-typedstatus. - Exercise 3: Take a variable declared with
letthat is assigned a string literal and passed to a function expecting a narrow literal union. Predict (then verify by reasoning through the widening rules) why the compiler rejects it, and rewrite it so it compiles without changing the function’s parameter type.
Summary
- A literal type represents one exact value — a specific string, number, or boolean — rather than the whole primitive category.
- Unions of literal types (
"a" | "b" | "c") act as lightweight, type-safe alternatives to enums and are one of the most common uses of literal types. letandvarwiden literal values to their base primitive type;constkeeps the narrow literal type.- Object literal properties are widened by default, even under
const, because properties remain mutable unless frozen withas const. as constrecursively locks a literal’s exact types and marks the structurereadonly.- All literal type checking happens at compile time only — the compiled JavaScript has no literal types, just plain runtime values.
