codejump

AcademyTypeScript

Literal types, unions and as const

Why a string in a variable stops being a literal, what as const freezes, and why a union of strings beats an enum almost every time.

3 min readUpdated

3 exercises

Waiting at the end of this page.

A value can be its own type

'idle' is a string. It is also a type — the type whose only value is 'idle'. Union a few of those and you have described a state machine:

TypeScript
type Status = 'idle' | 'loading' | 'done' | 'failed';

let status: Status = 'idle';
status = 'loadingg';  // Error, and the typo is caught at the assignment

This is the workhorse of TypeScript. It costs nothing at runtime — these are plain strings — and it gives autocomplete, exhaustiveness and a compile error on every typo.

Widening: why your literal turned back into a string

TypeScript widens a literal to its base type whenever the value could change:

TypeScript
const a = 'idle';   // type is 'idle'  — const, so it cannot change
let b = 'idle';     // type is string  — it could be reassigned

const config = { status: 'idle' };
config.status;      // type is string — the property is mutable

The third one is the surprise. const protects the binding, not the properties, so every field of an object literal widens. Which is why this fails:

TypeScript
function setStatus(status: Status) { /* ... */ }

const config = { status: 'idle' };
setStatus(config.status);
// Error: Argument of type 'string' is not assignable to parameter of type 'Status'.

Nothing is wrong with your value. The type just forgot what it was.

as const remembers

as const tells the compiler to keep the narrowest type it can, and to mark everything readonly:

TypeScript
const config = { status: 'idle' } as const;
// { readonly status: 'idle' }

setStatus(config.status);  // fine

const ROLES = ['admin', 'editor', 'viewer'] as const;
// readonly ['admin', 'editor', 'viewer']

type Role = (typeof ROLES)[number];
// 'admin' | 'editor' | 'viewer'

That last pair of lines is worth memorising. One array, written once, gives you both the runtime list to iterate and the union to type against — and they cannot drift apart, because the second is derived from the first.

Unions instead of enums

enum is one of the few TypeScript features that emits runtime code, and the object it emits behaves in ways nothing else in the language does.

TypeScript
enum Status { Idle, Loading }
// Numeric enums are bidirectional at runtime: Status[0] === 'Idle'.
// And this compiles, in older configurations:
const s: Status = 42;

A union of literals plus as const gives you the same ergonomics with none of that:

enumunion + as const
Runtime outputan objectnothing
Value in the debugger0'idle'
Value in the databaseneeds mappingthe string itself
Exhaustivenessworksworks
import type erasablenoyes

const enum avoids the runtime object but breaks under isolatedModules, which most bundlers now require. The union is the option that keeps working everywhere.

Where unions get uncomfortable

A union of objects without a common discriminant is hard to work with — you cannot read a property that does not exist on every member, so you end up with in checks everywhere. The fix is not to avoid unions, it is to add the discriminant:

TypeScript
// Hard to narrow
type Response = { data: string } | { error: string };

// One comparison narrows it, and a switch can be proved exhaustive
type Response =
  | { status: 'ok'; data: string }
  | { status: 'error'; error: string };

The second version is also the one that survives a third case being added next quarter.

Now practice it

2 challenges · 1 quiz

Reading this page is the cheap half. These are the exercises that make you use it.