Literals & Widening
A literal is a one-member set
Section titled “A literal is a one-member set”A literal type is a type whose only value is a single specific literal. "GET" is a type; the only value assignable to it is the string "GET". Same for 42, true, and so on.
// @expect-error TS2322let method: "GET" = "GET";method = "POST"; // ❌ Type '"POST"' is not assignable to type '"GET"'.On their own, literal types are rarely useful. Their power comes from unions of literals, which model “one of these exact values”:
type Method = "GET" | "POST" | "PUT" | "DELETE";type Dice = 1 | 2 | 3 | 4 | 5 | 6;This is how you replace loose string parameters with precise, self-documenting, autocompleting ones.
Widening: why literals keep disappearing
Section titled “Widening: why literals keep disappearing”TypeScript widens a literal to its general type in places where the value could change. The rule you met in Foundations: const keeps the literal, let widens.
const a = "GET"; // type: "GET"let b = "GET"; // type: string (widened — b is reassignable)The same widening happens to object properties, because object properties are mutable by default:
// @expect-error TS2345const config = { method: "GET" };// type: { method: string } — NOT { method: "GET" }
function send(m: "GET" | "POST") {}send(config.method); // ❌ string is not assignable to "GET" | "POST"Even though config is a const, its method property can be reassigned (config.method = "POST" is legal), so the compiler widens it to string. This surprises everyone once.
as const: freeze the narrow type
Section titled “as const: freeze the narrow type”A const assertion (as const) tells the compiler “treat this value as deeply immutable, and keep every literal narrow.”
function send(m: "GET" | "POST") {}
const config = { method: "GET" } as const;// type: { readonly method: "GET" }
send(config.method); // ✅ now "GET" survivesas const does three things at once:
- Literals stay literal —
"GET"instead ofstring. - Properties become
readonly— reflecting the immutability you promised. - Arrays become
readonlytuples —[1, 2]becomesreadonly [1, 2], notnumber[].
flowchart TB
val["value: { method: 'GET' }"] -->|default| wide["type: { method: string }<br/>(widened, mutable)"]
val -->|as const| narrow["type: { readonly method: 'GET' }<br/>(literal, readonly)"] A very common use: literal config objects
Section titled “A very common use: literal config objects”as const shines for lookup tables and configuration where you want the exact keys and values in the type:
const ROUTES = { home: "/", profile: "/profile", settings: "/settings",} as const;
type Route = typeof ROUTES[keyof typeof ROUTES];// type: "/" | "/profile" | "/settings"Without as const, ROUTES would be { home: string; profile: string; settings: string } and you’d lose every specific path. With it, you can derive precise union types straight from the data — a pattern you’ll see constantly in the type-level module.
satisfies: check without widening (4.9)
Section titled “satisfies: check without widening (4.9)”as const freezes the literals but gives you no check that the object matches a contract. An annotation gives you the check but replaces the inferred type with the annotation. satisfies does the check and keeps the narrow inferred type:
// @expect-error TS2339type Theme = Record<string, string | [number, number, number]>;
const theme = { primary: "#0af", accent: [255, 128, 0],} satisfies Theme;
theme.accent[0]; // ✅ number — still a tuple, not widened to the Theme value typetheme.typo; // ❌ property does not exist — the object is fully knownCombine it with as const ({ … } as const satisfies Theme) when you want readonly literal members and validation. To keep literals through a generic function call, see const type parameters in the Generics module.
The satisfies footgun: it keeps the inferred type, not the declared one
Section titled “The satisfies footgun: it keeps the inferred type, not the declared one”satisfies is often described as “an annotation that also checks” — but it does not become that annotation. The variable’s type stays whatever TypeScript inferred from the literal, which is usually narrower than the type you validated against. That narrowness cuts both ways:
type Channel = string | number[];
let palette = { red: [255, 0, 0], note: "middle-c",} satisfies Record<string, Channel>;
palette.red.push(0); // ✅ good -- inferred as number[], not widened to string | number[]// @expect-error TS2322type Channel = string | number[];
let palette = { red: [255, 0, 0], note: "middle-c",} satisfies Record<string, Channel>;
palette.red = "not an array anymore"; // reassigning within the DECLARED union, Channel// ❌ Type 'string' is not assignable to type 'number[]'.palette.red’s real type is number[] — the inferred literal type — not Channel. Reassigning it to a string is perfectly legal for the declared type Record<string, Channel>, but satisfies never widens the variable to that declared type, so the assignment fails against the narrower inferred one. Compare a plain annotation, which uses the declared type as the variable’s actual type and has no such restriction:
type Channel = string | number[];
let palette: Record<string, Channel> = { red: [255, 0, 0], note: "middle-c",};
palette.red = "not an array anymore"; // ✅ fine -- the declared type is the wide unionReach for satisfies when you want the narrow inferred type to flow onward (the common, desirable case). Reach for a plain annotation instead when the variable itself needs to keep accepting every member of the declared type over its lifetime — satisfies checks once, at the literal, and then gets out of the way.
satisfies keeps the inferred type
type Channel = string | number[];
let palette = { red: [255, 0, 0], note: "middle-c",} satisfies Record<string, Channel>;
palette.red.push(0); // ✅ number[] -- the narrow inferred typeconsole.log(palette.red);
// palette.red = "nope"; // would be TS2322 -- red's real type is number[], not Channel Mental Model
Section titled “Mental Model”flowchart LR
lit["Literal value written
('GET', 42, true)"] --> ctx{"Could this binding change?"}
ctx -->|"no -- const, readonly prop"| narrow["Stays the literal type"]
ctx -->|"yes -- let, mutable prop"| wide["Widens to the base type
(string, number, boolean)"]
narrow --> asconst["as const locks the whole value tree
(nested props + arrays too)"]
wide -->|"satisfies"| checked["Checked against a type,
keeps its own inferred type"] Every literal starts as the exact value you wrote. From there TypeScript asks one question — “could this binding be reassigned?” — and only widens when the answer is yes. as const is a blanket “no” applied recursively to an entire value (object properties become readonly, arrays become tuples); satisfies doesn’t touch widening at all, it only checks the already-inferred type against a contract and hands that same inferred type back.
Trade-offs
Section titled “Trade-offs”| Approach | Benefit | Cost |
|---|---|---|
Plain literal (let x = "GET") | Simplest to write | Widens to string — loses the exact value the moment it’s reassignable |
as const | Locks every literal, nested property, and array element at once | Everything becomes readonly — mutation needs a fresh non-const copy |
Annotation (: Theme) | Variable keeps exactly the declared type | Excess properties are hidden, and the inferred literal precision is discarded |
satisfies | Validates against a type while keeping the narrow inferred type | The variable is not the declared type — a later assignment must satisfy the narrower inferred shape, not the wider contract |
Common Misconceptions
Section titled “Common Misconceptions”constalone keeps every literal narrow.constonly stops reassignment of the binding; aconstobject’s properties still widen (const config = { method: "GET" }givesmethod: string) unless you addas const.satisfiesmakes the variable have the type you satisfied against. It doesn’t — the variable keeps its own inferred type, which is usually narrower. Assigning back a value that’s valid for the declared type but not the inferred one still fails.as constand a type annotation do the same job. An annotation replaces the type with what you wrote (hiding extra properties, widening literals);as constkeeps the literal, narrow,readonlytype inferred from the value itself — the two can even be combined.
Reading AI-generated code
Section titled “Reading AI-generated code”Before trusting a generated literal or config object, check for:
- A config object passed to a function expecting a literal union, without
as const— a plain object literal’s properties widen tostring/number, and the call only fails once the union check runs, often several functions away from the actual object. satisfiesused where the author actually wanted an annotation (or vice versa) — look at what happens on the next assignment to the variable, not just the first one, since that’s where the inferred-vs-declared gap shows up.as constapplied to something that’s about to be mutated — a spread or a.pushon a value markedas constis areadonly-violation error waiting to happen, not a runtime bug the type system missed.
🐛 Spot the bug
const defaultConfig = { method: "GET", retries: 3 };
function send(m: "GET" | "POST") {}
function withOverrides(overrides: Partial<typeof defaultConfig>) {const merged = { ...defaultConfig, ...overrides };send(merged.method);}Show the answer
defaultConfig has no as const, so typeof defaultConfig infers { method: string; retries: number } — the literal "GET" already widened to string at the point of declaration. Spreading it into merged doesn’t recover the literal, so merged.method is string, and the real diagnostic is TS2345: Argument of type 'string' is not assignable to parameter of type '"GET" | "POST"'. The fix is const defaultConfig = { method: "GET", retries: 3 } as const; (or annotate method with the literal union directly), so the literal survives the spread.