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 let or var, TypeScript assumes the variable might be reassigned later, so it widens the literal to its base primitive type. let x = "red" gives x the type string, not "red".
  • With const, the variable can never be reassigned, so TypeScript keeps the narrow literal type. const x = "red" gives x the 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" } infers obj.mode as string, since obj.mode = "light" is legal JavaScript.
  • The as const assertion overrides this default: it tells the compiler “treat every value in this expression as its most specific literal type, and make the whole structure readonly.” This is why { mode: "dark" } as const gives mode the type "dark" instead of string.

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 plain string parameter 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 const instead of let whenever a variable’s value should stay narrow, since let silently widens literal inference.
  • Reach for as const when 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 enum for 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 const and derive the type from it with typeof and indexed access, rather than maintaining two separate lists by hand.

Practice Exercises

  • Exercise 1: Define a type alias TrafficLight that is a union of the literal strings "red", "yellow", and "green". Write a function nextLight that takes a TrafficLight and returns the next color in the cycle red → green → yellow → red.
  • Exercise 2: Create a const object literal representing an HTTP response with a status property that should be exactly 200, 404, or 500. Use as const so the property keeps its literal numeric type, then write a function that only accepts objects with that literal-typed status.
  • Exercise 3: Take a variable declared with let that 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.
  • let and var widen literal values to their base primitive type; const keeps the narrow literal type.
  • Object literal properties are widened by default, even under const, because properties remain mutable unless frozen with as const.
  • as const recursively locks a literal’s exact types and marks the structure readonly.
  • All literal type checking happens at compile time only — the compiled JavaScript has no literal types, just plain runtime values.