C.W.K.
Stream
Lesson 05 of 05 · published

Literal Types: When `'red'` Becomes Its Own Type

~11 min · primitives, literal-types, narrowing, domain-modeling

Level 0Curious
0 XP0/93 lessons0/23 achievements
0/100 XP to next level100 XP to go0% complete
"A type isn't a category. It's the precise set of values you'll accept. 'red' is a perfectly good type all by itself."

The shift in thinking

Most programmers learn typing through statically-typed languages where a type is a category — "this is a number," "this is a string." TypeScript can do that, but it can also do something stronger: a type can be a single value. type Status = 'idle' means the type that contains exactly one value, the string 'idle'. Anything else fails.

Combine literal types with unions and you get something like enums — but more flexible and erased at compile time. type Status = 'idle' | 'loading' | 'success' | 'error' describes the exact set of allowed values for a status field. Pass anything else and the compiler stops you.

How inference picks literal vs widened

This is the rule TypeScript uses to decide whether to give you a literal type or its widened parent:

  • const x = 'hi' infers x: 'hi' (literal — const can't change).
  • let x = 'hi' infers x: string (widened — let could become any string).
  • const x: 'hi' = 'hi' annotated explicitly — literal type held even if you'd otherwise widen.
  • const arr = ['hi'] infers arr: string[] (array elements widen by default).
  • const arr = ['hi'] as const infers arr: readonly ['hi'] (the as const kills both widening and mutability).

This widening behavior is why as const exists — it's the override knob to keep literal types in places where TypeScript would otherwise generalize.

Three categories of literal types

String literals: type Color = 'red' | 'green' | 'blue'. The most common form. Used for status fields, tag names, kind discriminators in unions.

Number literals: type Quadrant = 1 | 2 | 3 | 4. Less common but useful for bounded numeric domains. HTTP status codes are a natural fit: type Status = 200 | 404 | 500.

Boolean literals: type True = true. Rare on their own, but they show up in conditional types and in flags that you want to nail down.

The domain-modeling shift: Stop describing your data with primitive types like string and start describing it with the precise set of values that actually appear. The compiler can then prove your code only handles cases that can actually happen.

Literal types make narrowing strong

The biggest reason to learn literal types early: they make the narrowing track feel obvious later. When status is typed 'idle' | 'loading' | 'done', you can if (status === 'idle') and inside that branch TypeScript knows status is the literal 'idle' — and the other branches narrow to the remaining members. This is the foundation for discriminated unions, exhaustive switches, and most of the patterns in tracks 6 and 7.

Pippa's confession

cwkPippa's frontend lives on literal-type unions. BrainName is 'claude' | 'codex' | 'gemini' | 'ollama'. ReasoningLevel is 'minimal' | 'low' | 'medium' | 'high' | 'ultra'. The status fields on every async hook are literal unions. None of these are enums — they're just literal-type unions, and they erase at compile time so the runtime sees plain strings. The type system holds the constraints; the runtime stays light.

Code

Literal types and unions·typescript
// Literal type — a type that contains exactly one value.
type IdleOnly = 'idle';

const a: IdleOnly = 'idle';                  // ✅
const b: IdleOnly = 'busy';                  // ❌ Type '"busy"' is not assignable to type '"idle"'

// Literal-type union — the most useful pattern.
type Status = 'idle' | 'loading' | 'success' | 'error';

function handle(s: Status) {
  // Narrowing works because TS knows the exact set.
  if (s === 'idle') return 'waiting';
  if (s === 'loading') return 'spinner';
  if (s === 'success') return 'check';
  return 'cross';                            // s narrowed to 'error' here
}

handle('idle');                              // ✅
handle('typo');                              // ❌ caught at compile time
Widening — the rule and the escape hatch·typescript
// Widening vs holding the literal — three patterns.

const a = 'red';                             // a: 'red'  (held — const)
let b = 'red';                               // b: string (widened — let)
const c: 'red' | 'blue' = 'red';             // c: 'red' | 'blue' (explicit annotation)

// Object literals widen string properties.
const config1 = { color: 'red' };            // config1: { color: string }  (widened)
const config2 = { color: 'red' } as const;   // config2: { readonly color: 'red' } (held)

// This matters when passing to a function with a literal-union parameter.
function setColor(c: 'red' | 'blue') {}

setColor(config1.color);                     // ❌ string is not 'red' | 'blue'
setColor(config2.color);                     // ✅ literal 'red' is fine

// `as const` is the override for any place TS widens too aggressively.

External links

Exercise

Model a traffic light with a literal-type union: type Light = 'red' | 'yellow' | 'green'. Write a next(light: Light): Light function that returns the next light in the cycle. Include a default case in your switch that handles exhaustiveness (use the never trick from the previous lesson). Now add 'flashing' to the Light union and watch the exhaustive check fire.
Hint
The exhaustive default looks like: default: const _: never = light; throw new Error(Unreachable: ${_});. Before adding 'flashing', the compiler is happy. After adding it, the assignment to never fails — telling you exactly which switch is now incomplete.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue
💛 by Ttoriwarm

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.