Skip to content

Literals & Widening

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 TS2322
let 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.

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 TS2345
const 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.

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" survives

as const does three things at once:

  1. Literals stay literal — "GET" instead of string.
  2. Properties become readonly — reflecting the immutability you promised.
  3. Arrays become readonly tuples — [1, 2] becomes readonly [1, 2], not number[].
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)"]
Widening vs as const

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.

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 TS2339
type 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 type
theme.typo; // ❌ property does not exist — the object is fully known

Combine 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 TS2322
type 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 union

Reach 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 type
console.log(palette.red);
// palette.red = "nope"; // would be TS2322 -- red's real type is number[], not Channel
Open in TS Playground
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"]
Where a value's type comes from vs where it settles

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.

ApproachBenefitCost
Plain literal (let x = "GET")Simplest to writeWidens to string — loses the exact value the moment it’s reassignable
as constLocks every literal, nested property, and array element at onceEverything becomes readonly — mutation needs a fresh non-const copy
Annotation (: Theme)Variable keeps exactly the declared typeExcess properties are hidden, and the inferred literal precision is discarded
satisfiesValidates against a type while keeping the narrow inferred typeThe variable is not the declared type — a later assignment must satisfy the narrower inferred shape, not the wider contract
  • const alone keeps every literal narrow. const only stops reassignment of the binding; a const object’s properties still widen (const config = { method: "GET" } gives method: string) unless you add as const.
  • satisfies makes 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 const and a type annotation do the same job. An annotation replaces the type with what you wrote (hiding extra properties, widening literals); as const keeps the literal, narrow, readonly type inferred from the value itself — the two can even be combined.

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 to string/number, and the call only fails once the union check runs, often several functions away from the actual object.
  • satisfies used 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 const applied to something that’s about to be mutated — a spread or a .push on a value marked as const is a readonly-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.

What is the type of const x = "GET" versus let y = "GET"?
Why is method typed as string in const config = { method: "GET" }?
What does as const do?
Why is a union of literals like "GET" | "POST" better than string for a parameter?
Does this compile, and why?
type Channel = string | number[];

let palette = {
  red: [255, 0, 0],
  note: "middle-c",
} satisfies Record<string, Channel>;

palette.red = "not an array anymore";
Why does send(merged.method) fail to compile?
const defaultConfig = { method: "GET", retries: 3 };
function send(m: "GET" | "POST") {}
function withOverrides(overrides: Partial<typeof defaultConfig>) {
  const merged = { ...defaultConfig, ...overrides };
  send(merged.method);
}