Literal types: single values as types, and the as const that keeps them
A literal type is a single value as a type: "GET", 200, true. Unions of literals are lightweight enums. const keeps literals, let widens them, and as const locks deep readonly literals on objects, arrays, and tuples — the trick that powers config-as-types.
You define a method config object { method: "GET" } and pass it to fetch. The compiler rejects it: Type 'string' is not assignable to type '"GET" | "POST" | ...'. But you wrote "GET" — how did it become string? This is widening, and the one-line fix (as const) is the same trick that lets a whole config file be both runtime data and a precise type. Literal types are how TypeScript turns specific values into compile-time guarantees.
A single value, used as a type
Every primitive value can also be a type: "GET" is a type whose only inhabitant is the string "GET". Likewise 200, true, 42n. On their own these are rarely useful — but as unions they become precise, self-documenting domains:
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type StatusCode = 200 | 201 | 400 | 404 | 500;
type Toggle = true | false; // i.e. boolean
function request(method: HttpMethod, url: string) { /* ... */ }
request("GET", "/users"); // ✅
request("PATCH", "/users"); // Error: Argument of type '"PATCH"' is not
// // assignable to parameter of type 'HttpMethod'.A literal union is a closed set the checker enforces and your editor autocompletes. This is the most common “enum” in modern TypeScript — and it has zero runtime footprint.
Widening, revisited: where literals come from and get lost
You met widening in the inference lesson. Literals are exactly what widening throws away. The binding keyword decides:
let m = "GET";
// ^? let m: string — `let` widens; the literal is gone
const m2 = "GET";
// ^? const m2: "GET" — `const` keeps the literalSo const already gives you a literal for primitives. The trap is objects, where const does not reach into properties:
const config = { method: "GET" };
// ^? const config: { method: string } — property widened to string
request(config.method, "/x");
// Error: Argument of type 'string' is not assignable to parameter of type 'HttpMethod'.Here’s the Hook’s bug exactly: config.method widened to string because the property is mutable, so it no longer fits the HttpMethod union. That’s the cost of structural typing meeting widening.
as const: deep, readonly, literal
The fix is a const assertion. as const does three things at once to its operand: makes every property readonly, prevents widening (keeps literal types), and turns array literals into tuples instead of arrays.
const config = { method: "GET", retries: 3 } as const;
// ^? const config: { readonly method: "GET"; readonly retries: 3 }
request(config.method, "/x"); // ✅ now config.method is the literal "GET"Watch it work top to bottom on nested data, arrays, and tuples:
const before = ["a", "b"];
// ^? const before: string[]
const after = ["a", "b"] as const;
// ^? const after: readonly ["a", "b"] — a readonly tuple of literals
const route = { path: "/users", methods: ["GET", "POST"] } as const;
// ^? const route: {
// readonly path: "/users";
// readonly methods: readonly ["GET", "POST"];
// }Without as const, methods would be string[]; with it, it’s the exact tuple readonly ["GET", "POST"]. This is the engine behind “config-as-types”: author a plain object literal, append as const, and derive types directly from the runtime value (typeof config, config["methods"][number]) — single source of truth, no duplication.
▸Why this works
as const is not a runtime cast — TypeScript types are erased, so the emitted JavaScript is identical with or without it. It only changes how the compiler reads the literal: don’t widen, mark readonly, make tuples. That’s why it’s safe to sprinkle on config objects: zero runtime cost, pure type information.
When widening bites — and the fix
The classic failure: a config object whose literal keys silently widen, then get rejected by a function expecting the literal union.
type Level = "debug" | "info" | "warn" | "error";
function setLevel(l: Level) { /* ... */ }
const settings = { level: "warn" };
setLevel(settings.level);
// Error: Argument of type 'string' is not assignable to parameter of type 'Level'.
// Fixes (pick one):
const settings2 = { level: "warn" as const }; // narrow one property
const settings3 = { level: "warn" } as const; // narrow the whole object
const settings4 = { level: "warn" as Level }; // annotate to the target unionAny of these keep level precise enough to satisfy Level. The general lesson: when you build data destined for a literal-union slot, you must stop widening — either with as const or by typing the variable to the union directly.
Literal unions vs enum
Before you reach for enum, ask yourself: does this value need to exist as a runtime object, or do you just want the checker to reject anything outside a known set? Most of the time it’s the latter — which is exactly what literal unions do with zero runtime cost. Here’s how they compare:
| Aspect | Literal union | enum |
|---|---|---|
| Runtime emit | None — pure type, erased | Emits a JS object (numeric enum is bidirectional) |
| Assignability | Structural — a matching string just works | Nominal-ish — must reference the enum member |
| Values | The literal itself is the value (“GET”) | Member maps to a separate value |
| JSON / API friendliness | High — the wire value is the type | Lower — need to map to/from member |
Teams favor as const unions because they have no runtime cost, serialize transparently (the API sends "GET", not an opaque enum slot), and stay structural — a plain "GET" from anywhere just fits. enum still earns its place for bit flags or when you want a closed namespace object at runtime, but it’s no longer the default reach.
▸Why this works
The numbers on both tradeoffs. Runtime: a literal union erases to nothing, while an enum compiles to a runtime object — a numeric enum emits a reverse-mapped IIFE (Color[Color.Red] = "Red") that a non-const enum can’t tree-shake, so each one costs roughly 150–400 bytes of shipped JS that a literal union costs zero. Across an app with ~30 status/role/method enums that’s single-digit KB of dead weight you simply don’t pay with as const. Authoring: the literal side has its own cost — narrowness sacrifices reusability. as const on a large config makes every member readonly, so passing it to anything typed with a mutable array ((x: string[]) => void) fails with readonly 'a'[] is not assignable to string[], and a deep as const on a big config object measurably enlarges the inferred type the editor must hold — on a 200-key feature-flag object, as const added a noticeable hover/IntelliSense delay versus a hand-written interface. The senior tradeoff: reach for the precise literal/as const where the closed set is the whole point (HTTP methods, log levels, a single-source-of-truth config you derive types from); but when a value needs to flow into mutable, widely-reused APIs, the literal’s readonly-ness becomes friction — there, widen deliberately (annotate to the union, or drop as const) and trade a sliver of precision for reusability.
▸Common mistake
A common slip: writing const x = "GET" and assuming the property of an object you build from it stays "GET". Primitive const keeps the literal, but the moment it lands in an object property the property widens to string again unless the object is as const. Widening is about the slot, not the source value.
What is the inferred type of `tags` in `const t = { tags: ['a', 'b'] } as const`?
Order these by how SPECIFIC the inferred type is, from widest to narrowest:
- 1 let x = 'GET' -> string
- 2 const x = 'GET' -> 'GET'
- 3 const o = { m: 'GET' } -> { m: string }
- 4 const o = { m: 'GET' } as const -> { readonly m: 'GET' }
- 01What is a literal type, and why are literal unions the preferred lightweight enum in modern TypeScript?
- 02Why does `const config = { method: 'GET' }` infer `method` as `string`, and what are the ways to keep it as the literal?
- 03What exactly does `as const` do, and how does it enable config-as-types?
Literal types close the foundations unit by tying widening (from the inference lesson) to a practical superpower: as const turns runtime data into precise types. You now have the full base layer — structural typing, inference, the special types any/unknown/never, and literals. The natural next step is unions and intersections (your deepensInto target in unit 02): literal unions were your first taste of |, and the next unit generalizes it — combining types with | and &, then narrowing those unions back down to a single member, the exact mechanism that makes unknown usable and never the marker of exhaustiveness. Now when you see Type 'string' is not assignable to type '"GET" | ...', you’ll know exactly what happened — a mutable slot widened the literal — and reach for as const before anything else.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.