Literal Types & const Assertions
Reviewed & published by Brayan K
A literal type restricts a value to one exact constant — like the type "active" or 42 — and combined into unions they give you a precise, enum-like set of allowed values.
Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Write string and number literal types that pin a value to one constant
- Combine literals with | into an enum-like set of allowed options
- Understand widening (let → string) vs narrowing (const → literal)
- Use 'as const' to freeze a value into its readonly literal form
- Derive a union of literals from an array with typeof arr[number]
- Choose literal unions where they're simpler than full enums
💡 Real-World Analogy
A plain string type is like a blank text box — you can type anything. A literal union is like a dropdown menu: the field still holds a string, but only the specific options on the list are valid. "S" | "M" | "L" is the dropdown; "XXL" simply isn't on it, so the compiler rejects it before your program ever runs.
1. Literal Types: One Exact Value
A literal type is a type made of a single constant value. "active" as a type accepts only the string "active"; 6 as a type accepts only the number 6. By themselves they're rarely useful — their power shows when you join several with | to form a fixed set, like "north" | "south" | "east" | "west".
// One exact value as a type:
let mode: "dark" = "dark";
// mode = "light"; // ❌ Type '"light"' is not assignable to type '"dark"'
// The real power: a UNION of literal types = a fixed set of options:
type Direction = "north" | "south" | "east" | "west";
type Dice = 1 | 2 | 3 | 4 | 5 | 6;
function move(pos: [number, number], dir: Direction): [number, number] {
// ... dir can ONLY be one of the four strings
return pos;
}
// move(p, "up"); // ❌ '"up"' is not assignable to parameter of type 'Direction'// A LITERAL TYPE is a type that is ONE exact value, not a whole category.
// "active" is a type with exactly one member: the string "active".
// In TypeScript: let mode: "dark" = "dark"; // only "dark" is allowed
// At runtime these are ordinary strings/numbers — the constraint is in the types.
// A union of literals = a fixed menu of allowed values (very enum-like):
// type Direction = "north" | "south" | "east" | "west";
const moves = ["north", "east", "east", "south"];
function move(position, direction) {
switch (direction) {
case "north": return [position[0], position[1] + 1];
case "south": return [position[0], position[1] - 1];
case "east": return [position[0] + 1, position[1]];
case "west": return [position[0] - 1, position[1]];
default: return position;
}
}
let pos = [0, 0];
for (const m of moves) {
pos = move(pos, m);
}
console.log("final position:", pos); // final position: [ 2, 0 ]
// Numeric literals work too:
// type Dice = 1 | 2 | 3 | 4 | 5 | 6;
function isWinningRoll(roll) {
return roll === 6;
}
console.log(isWinningRoll(6)); // true
console.log(isWinningRoll(3)); // false2. Widening vs Narrowing
When you initialise a variable, TypeScript decides between the broad type and the exact literal. A let can be reassigned, so TypeScript widens let theme = "dark" to string. A const never changes, so it narrows const fixed = "dark" to the literal type "dark". Understanding this explains a lot of "why is my type just string?" surprises.
let theme = "dark"; // type: string (widened — reassignable)
const fixed = "dark"; // type: "dark" (narrow literal — fixed)
function setMode(m: "dark" | "light") { /* ... */ }
setMode(fixed); // ✅ "dark" fits
setMode(theme); // ❌ Argument of type 'string' is not assignable to '"dark" | "light"'// WIDENING: when TypeScript infers a GENERAL type instead of the literal.
// These are runtime-identical, but TypeScript types them differently:
let theme = "dark"; // inferred type: string (WIDENED — let can be reassigned)
const fixed = "dark"; // inferred type: "dark" (NARROW literal — const never changes)
// We can SEE the runtime values are the same:
console.log(theme, fixed); // dark dark
console.log(theme === fixed); // true
// Why it matters: a 'let' string can later hold any string, so TypeScript
// must widen it. A 'const' can never be reassigned, so it stays the exact literal.
theme = "light"; // ✅ allowed for a string-typed let
console.log("theme is now:", theme); // theme is now: light
// In a .ts file:
// let theme = "dark"; // type: string
// const fixed = "dark"; // type: "dark" (a literal type)3. as const Assertions
An as const assertion tells TypeScript: "infer the narrowest possible type and make it readonly." Object fields keep their literal types instead of widening, arrays become readonly tuples of literals, and you can derive a union directly from data: typeof SIZES[number] turns ["sm","md","lg"] as const into "sm" | "md" | "lg" — one source of truth for both the values and the type.
const config = { role: "admin", level: 3 } as const;
// config.role -> "admin" (not string), and readonly
const SIZES = ["sm", "md", "lg"] as const;
type Size = typeof SIZES[number]; // "sm" | "md" | "lg"
let s: Size = "md"; // ✅
// let bad: Size = "xl"; // ❌ '"xl"' is not assignable to type 'Size'// 'as const' freezes a value into its NARROWEST, READONLY literal form.
// Without it, an object's fields widen to general types:
// const a = { role: "admin" }; // role: string
// const b = { role: "admin" } as const; // role: "admin" (readonly)
// At runtime, 'as const' makes the object deeply frozen-like (readonly in TS).
// Here we show the underlying data it describes:
const config = {
role: "admin",
level: 3,
tags: ["beta", "internal"],
};
console.log(config.role); // admin
console.log(config.level); // 3
console.log(config.tags[0]); // beta
// A super-common pattern: derive a union of literals FROM an array via 'as const'.
// const SIZES = ["sm", "md", "lg"] as const;
// type Size = typeof SIZES[number]; // "sm" | "md" | "lg"
const SIZES = ["sm", "md", "lg"];
console.log("allowed sizes:", SIZES.join(", ")); // allowed sizes: sm, md, lg
console.log("is 'md' allowed?", SIZES.includes("md")); // is 'md' allowed? true
// In a .ts file:
// const config = { role: "admin", level: 3 } as const; // all fields readonly literals
// const SIZES = ["sm", "md", "lg"] as const;
// type Size = typeof SIZES[number]; // "sm" | "md" | "lg"🎯 Your Turn
Model a traffic light whose colour is one of three literals. Fill in the two blanks marked ___ so the cycle goes red → green → amber → red, then run it.
// 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".
// We're modelling a traffic light whose colour is one of three literals:
// type Light = "red" | "amber" | "green";
function nextLight(light) {
// Fill in the literal each colour should become:
if (light === "red") return ___; // 👉 the string "green"
if (light === "green") return "amber";
if (light === "amber") return "red";
return light;
}
console.log(nextLight("red")); // green
console.log(nextLight("green")); // amber
console.log(nextLight(___)); // 👉 pass "amber" to get "red"
// ✅ Expected output:
// green
// amber
// redCommon Errors (and the fix)
- ❌ "Type 'string' is not assignable to type '"dark" | "light"'" — you passed a widened let string into a literal union. ✅ Use a const, annotate the literal type, or add as const.
- ❌ "Type '"up"' is not assignable to type 'Direction'" — a typo or value outside the allowed set. ✅ Use one of the exact literals the union lists.
- ❌ Object field widened unexpectedly — { role: "admin" } infers role: string. ✅ Add as const (or annotate) to keep role: "admin".
- ❌ "Cannot assign to 'x' because it is a read-only property" — you tried to mutate a value frozen by as const. ✅ Build a new value instead of mutating the readonly one.
Pro Tips
- 💡 One source of truth: declare options as const OPTS = [...] as const and derive the type with typeof OPTS[number] — change the array and the type updates automatically.
- 💡 Prefer literal unions over enums for simple string sets — they're lighter and need no runtime code.
- 💡 Annotate function params with literal unions to get autocomplete of the exact allowed values in your editor.
- 💡 as const on tuples preserves both length and element literals — handy for fixed configuration like [200, "OK"] as const.
Mini-Challenge: Door State Machine
No blanks this time — just a brief and a starting outline. Build the literal-typed state machine yourself, run it, and check your output against the example in the comments.
// 🎯 MINI-CHALLENGE: a tiny state machine with literal types
// A door is one of: "locked" | "closed" | "open"
// Build transition(state, action) where action is "lock" | "unlock" | "push":
//
// "locked" + "unlock" -> "closed"
// "closed" + "lock" -> "locked"
// "closed" + "push" -> "open"
// "open" + "push" -> "closed"
// anything else -> return the SAME state unchanged
//
// Then run these and log each result:
// transition("locked", "unlock") -> "closed"
// transition("closed", "push") -> "open"
// transition("open", "push") -> "closed"
//
// ✅ Expected output:
// closed
// open
// closed
// your code here🎉 Lesson Complete
- ✅ A literal type is one exact value used as a type ("active", 42)
- ✅ A union of literals is a precise, enum-like set of allowed options
- ✅ Widening: let x = "a" → string; narrowing: const x = "a" → "a"
- ✅ as const locks a value into its narrowest readonly literal form
- ✅ Derive a type from data with typeof ARR[number] for a single source of truth
Practice quiz
What is a literal type?
- A type that accepts any string
- A runtime literal value
- A type whose only value is one exact constant
- A union of all numbers
Answer: A type whose only value is one exact constant. A literal type like "active" or 42 accepts only that one exact value.
What does a union of literals like "sm" | "md" | "lg" give you?
- A precise, enum-like set of allowed values
- A random value
- Any string
- A tuple
Answer: A precise, enum-like set of allowed values. Joining literals with | creates a fixed set of allowed options, like a dropdown menu.
What type does let theme = "dark" infer?
- "dark"
- any
- literal
- string
Answer: string. A let can be reassigned, so TypeScript widens it to string.
What type does const fixed = "dark" infer?
- string
- "dark"
- any
- char
Answer: "dark". A const never changes, so it narrows to the literal type "dark".
What does as const do to a value?
- Freezes it into its narrowest readonly literal form
- Makes it mutable
- Converts it to a string
- Removes its type
Answer: Freezes it into its narrowest readonly literal form. as const infers the narrowest possible type and makes the value readonly.
For const SIZES = ["sm", "md", "lg"] as const, what is typeof SIZES[number]?
- string[]
- string
- "sm" | "md" | "lg"
- never
Answer: "sm" | "md" | "lg". typeof ARR[number] on an as const array yields the union of its element literals.
Why might passing a let string to a function expecting "dark" | "light" fail?
- The value is wrong
- The let was widened to string, which is not assignable to the literal union
- Functions cannot take strings
- let is not allowed in TypeScript
Answer: The let was widened to string, which is not assignable to the literal union. Even with a correct value, a widened string is not assignable to the narrower literal union.
Which is a valid numeric literal union type?
- type Dice = number(6)
- type Dice = int<6>
- type Dice = [1,6]
- type Dice = 1 | 2 | 3 | 4 | 5 | 6
Answer: type Dice = 1 | 2 | 3 | 4 | 5 | 6. Numeric literals combine with | just like strings: 1 | 2 | 3 | 4 | 5 | 6.
Compared with an enum, a literal union of strings is generally...
- Heavier and needs runtime code
- Lighter, needs no runtime object, and is type-safe
- Unsafe
- Only for numbers
Answer: Lighter, needs no runtime object, and is type-safe. A literal union is lighter and needs no runtime object; most teams prefer it for string sets.
Why declare options as const OPTS = [...] as const and derive the type with typeof OPTS[number]?
- It runs faster
- It disables type checking
- It keeps a single source of truth so the array and type stay in sync
- It is required syntax
Answer: It keeps a single source of truth so the array and type stay in sync. Deriving the type from the array means changing the array updates the type automatically.
Continue this course
- Previous: Union & Intersection Types
- Next: Enums — Numeric and string enums, const enums, and enum vs union-of-literals
- Quick reference: TypeScript cheat sheet › Basic Types
Frequently asked questions
What exactly is a literal type?
It is a type whose only allowed value is one specific literal — for example the type "active" accepts only the string "active", and the type 42 accepts only the number 42. On their own they're niche, but combined with | into a union (like "sm" | "md" | "lg") they describe a precise, finite set of allowed values.
What is the difference between widening and narrowing?
Widening is when TypeScript infers a general type (let x = "dark" gives type string), because a let can be reassigned. Narrowing keeps the exact literal (const x = "dark" gives type "dark"), because a const can never change. as const forces the narrow, literal form even on objects and arrays.
When should I use 'as const'?
Use as const when you want a value's literal types preserved and the value treated as readonly — typically for configuration objects, lookup tables, and arrays you want to turn into a union of literals via typeof SIZES[number]. It prevents accidental widening and accidental mutation.
Literal union vs enum — which should I pick?
A union of string literals (type Size = "sm" | "md" | "lg") is lighter, needs no runtime object, and is fully type-safe — most teams prefer it. Reach for an enum when you want a named runtime value, reverse mapping, or auto-incrementing numbers. You'll meet enums in the next lesson.