Literal Types: Exact Values, Widening, and `as const`
Learn to constrain values to specific strings, numbers, or booleans with literal types, understand how and when TypeScript widens literals during inference, and use as const to preserve exact literal types.
Literal types let you narrow a type down to one specific, exact value — like the string "up" rather than any string — which is the foundation for precise, self-documenting APIs and discriminated unions.
String, number, and boolean literal types
Just as string describes any string, the type "up" describes exactly one value: the string "up" and nothing else. Literal types exist for strings, numbers, and booleans alike ("up", 42, true are all valid types, each with exactly one possible value), and they're rarely used alone — their real power comes from combining several into a union, such as type Direction = "up" | "down" | "left" | "right";, which restricts a value to exactly those four strings and nothing else.
This gives you compile-time exhaustiveness and typo protection that a plain string parameter can't: passing "uup" to a function expecting Direction is a compile error, whereas a plain string parameter would accept any typo silently and only fail (or misbehave) at runtime. Editors also use literal unions to drive autocomplete, suggesting exactly the valid options as you type.
Numeric and boolean literal unions follow the same pattern — type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6; or type Toggle = true | false; (equivalent to boolean, though rarely written this way directly) — and are especially useful for representing a fixed, known set of valid numeric codes, like HTTP status categories or a limited set of configuration levels.
Widening, literal inference, and as const
When you declare a variable with let and initialize it with a literal, like let direction = "up";, TypeScript infers the wider type string, not the literal type "up" — this is called widening, and it happens because a let variable can be reassigned, so TypeScript assumes you'll want to assign other strings to it later. With const, however, TypeScript infers the literal type "up" directly for a primitive, because a const binding can never be reassigned, so the literal value is guaranteed to be the only value that variable will ever hold.
This distinction gets more complicated for object and array literals: even when declared with const, an object's properties are still individually mutable (const obj = { direction: "up" } still lets you write obj.direction = "down"), so TypeScript widens direction's inferred type to string inside the object, not "up" — the const only protects obj itself from reassignment, not its contents.
The as const assertion overrides this default widening everywhere it applies: appending as const to an object or array literal tells TypeScript to infer the narrowest possible literal type for every property and element, and to also treat the whole structure as readonly. const obj = { direction: "up" } as const; gives obj.direction the literal type "up", not string, and marks it readonly so it can't be reassigned even at the property level.
as const is especially valuable for defining a fixed set of options as an array and deriving a union type from it, avoiding having to write the literal union by hand and keep it in sync: const DIRECTIONS = ["up", "down", "left", "right"] as const; followed by type Direction = typeof DIRECTIONS[number]; produces exactly the same union as writing it manually, but from a single source of truth you can also iterate over at runtime.
Discriminated state values with literal types
Literal types are the mechanism behind discriminated unions, one of TypeScript's most powerful patterns for modeling state: each variant of a union gets its own literal value on a shared property (often called a "tag" or "discriminant"), like { status: "loading" }, { status: "success"; data: string }, and { status: "error"; message: string }, combined into type FetchState = Loading | Success | Error;.
Because each variant's discriminant property has a distinct literal type, TypeScript can narrow the whole union just by checking that one property in an if or switch statement — checking state.status === "success" inside a branch narrows state's type all the way down to the Success variant, making state.data accessible there and nowhere else, with no manual type assertions needed.
This pattern scales cleanly to switch statements with an exhaustiveness check: assigning the narrowed value in the default case to a variable of type never causes a compile error if a new variant is ever added to the union but a case for it is forgotten, catching an entire class of "forgot to handle the new state" bugs at compile time rather than in production.
Literal types, unions, and as const
Direction restricts values to exactly these four strings; any other string is a compile error. DIRECTIONS with as const becomes a readonly tuple of literal strings, not string[]. typeof DIRECTIONS[number] reads the union of all element types out of the tuple, producing the same union as Direction.
type Direction = "up" | "down" | "left" | "right";
function move(dir: Direction): void {
console.log(`Moving ${dir}`);
}
const DIRECTIONS = ["up", "down", "left", "right"] as const;
type DerivedDirection = typeof DIRECTIONS[number];Code Examples
move("up") compiles because "up" is one of the four literal values in the Direction union. move("sideways") fails to compile because "sideways" isn't one of the allowed literals, catching a typo or invalid value before the program ever runs. Expected result: Error: Argument of type '"sideways"' is not assignable to parameter of type 'Direction'.
let widened gets the wide type string because it could be reassigned. const narrowed keeps the literal type "up" because it can never change. Object properties widen to string by default even under const, unless as const is applied, which is why obj.direction fails when passed to move but objConst.direction succeeds. Expected result: move(objConst.direction) compiles; move(obj.direction) fails with: Argument of type 'string' is not assignable to parameter of type '"up" | "down" | "left" | "right"'.
Common mistake: Expecting a const object's property values to automatically keep their literal types — const only protects the variable binding from reassignment, not the literal-ness of nested property values, which still widen unless as const is used.
type Direction = "up" | "down" | "left" | "right";
function move(dir: Direction): void {
console.log(`Moving ${dir}`);
}
move("up");
move("sideways"); // Error: not assignable to Directionlet widened = "up"; // inferred as: string
const narrowed = "up"; // inferred as: "up"
const obj = { direction: "up" };
// obj.direction is inferred as string, not "up", despite const
const objConst = { direction: "up" } as const;
// objConst.direction is inferred as the literal type "up", and is readonly
function move(dir: "up" | "down" | "left" | "right") {}
move(objConst.direction); // OK
move(obj.direction); // Error: string is not assignable to the literal unionWidening vs. as const
const alone only narrows a primitive's own binding; nested object properties need as const to keep their exact literal types.
let x = "up"; -> inferred type: string (can be reassigned)
const y = "up"; -> inferred type: "up" (never reassigned)
const obj = { d: "up" }; -> obj.d inferred type: string (property still mutable)
const obj2 = { d: "up" } as const;
-> obj2.d inferred type: "up" (as const locks it down + readonly)Important Rules
Combine literal types into a union ("a" | "b" | "c") to restrict a value to a known, closed set of exact values — this is far stricter and safer than a plain string or number parameter.
Remember that let widens a literal initializer to its general type (string, number, boolean), while const preserves the literal type for a primitive — but only for the binding itself, not automatically for nested object or array properties.
Use as const on object and array literals whenever you need their exact literal values preserved, such as deriving a union type from an array of allowed options.
Pair discriminated unions (each variant tagged with its own literal value on a shared property) with a switch and a never-typed default case to get compile-time exhaustiveness checking.
Where this shows up
Literal type unions and discriminated unions appear throughout real TypeScript codebases: HTTP method types ("GET" | "POST" | "PUT" | "DELETE"), UI component variant props ("primary" | "secondary" | "danger"), Redux-style action types, and async state machines all rely on exact literal values plus as const to keep configuration arrays and their derived types in sync from a single source of truth.
const DIRECTIONS = ["up", "down", "left", "right"]; // inferred as string[] type Direction = typeof DIRECTIONS[number]; // resolves to just string, not the literal union const DIRECTIONS = ["up", "down", "left", "right"] as const; type Direction = typeof DIRECTIONS[number]; // resolves to "up" | "down" | "left" | "right"
Without as const, the array literal's elements widen to plain string, so typeof DIRECTIONS[number] is just string rather than the intended union of exact literal values — as const is what preserves each element's specific literal type.
[multiple-choice] Given let x = "up";, what type does TypeScript infer for x? A. The literal type "up" B. The general type string C. any D. never
B. The general type string — Because x is declared with let and can be reassigned, TypeScript widens the literal initializer "up" to the general type string rather than keeping the exact literal type.
[true-false] Declaring an object with const automatically preserves literal types for all of its property values. A. True B. False
B. False — const only prevents the variable binding itself from being reassigned; nested object properties are still mutable and widen to their general type (e.g. string instead of "up") unless the object literal is marked with as const.
[multiple-choice] What does appending as const to an array literal like ["up", "down"] change about its inferred type? A. It converts the array into a union type directly B. It infers a readonly tuple of the exact literal element values, instead of a mutable string[] C. It has no effect on the array's type D. It converts the array into an object
B. It infers a readonly tuple of the exact literal element values, instead of a mutable string[] — as const tells TypeScript to infer the narrowest possible type for the literal: a readonly tuple where each element keeps its specific literal type, rather than widening to a general and mutable string[].
Define a discriminated union type TrafficLight = { color: "red" } | { color: "yellow" } | { color: "green" };. Write a function nextColor(light: TrafficLight): TrafficLight that returns the next light in the red -> green -> yellow -> red cycle, using a switch statement with an exhaustiveness check in the default case.
type TrafficLight = { color: "red" } | { color: "yellow" } | { color: "green" }; function nextColor(light: TrafficLight): TrafficLight { switch (light.color) { case "red": return { color: "green" }; case "green": return { color: "yellow" }; case "yellow": return { color: "red" }; default: const exhaustiveCheck: never = light; throw new Error(Unhandled color: ${exhaustiveCheck}); } }
Assign the switch's default-case value to a variable typed never to get a compile error if a new color variant is ever added without being handled.