skip to content

What does the `const` modifier on a type parameter do — `declare function f<const T>(x: T): T` in TypeScript 5.0 and later — and when does it have no effect?

level: middleimportance: should knowfreq 40%

answer

  1. as if the caller wrote it
  2. inline expressions only
  3. readonly comes along for the ride
  4. constraint decides if readonly survives
  5. erased, so nothing is frozen

basics

~20 s

It makes inference treat the argument as if the caller had written a const assertion: object literals come back with readonly literal-typed properties, arrays as readonly tuples. It affects only expressions written at the call site, and nothing at runtime.

solid answer

~50 s

`const` on a type parameter, added in TypeScript 5.0, tells the checker to infer the argument as if it had been written with a const assertion. Object literals come back with `readonly` properties and literal-typed values, and array literals come back as readonly tuples, so `f({ mode: "dark" })` infers `{ readonly mode: "dark" }` rather than `{ mode: string }`. Two limits matter in practice. It only affects expressions written *at the call site*: if the caller passes a variable, that variable's type was already widened at its declaration and the modifier changes nothing. And the `readonly` part is dropped where the constraint would reject it — `<const T extends string[]>` infers the mutable tuple `["a", "b"]`, while `<const T extends readonly string[]>` infers `readonly ["a", "b"]`. It is purely a compile-time inference hint; nothing is frozen and no value is made immutable.

go deeper

for a junior

Recognise the syntax <const T> and know it makes TypeScript remember the exact values the caller wrote, instead of widening them to string or number.

for a middle

Explain that inference behaves as if a const assertion were applied to the argument expression, and name the call-site-only limitation and the readonly-versus-constraint interaction.

for a senior

Show judgment about where the precision is worth it, and be ready to explain why adding the modifier to a published signature can break consumers whose code expects mutable types.

for a principal

Own the API-wide policy: deeply readonly literal types leak into declaration files, error text and check time, so decide deliberately which entry points earn that cost.

## The problem it solves TypeScript's default inference is tuned for the common case: it widens literals and array literals so that the resulting types are usable for ordinary mutable code. That is exactly wrong for configuration-shaped APIs, where the whole point is to remember precisely what the caller wrote. ```ts declare function conf<T extends Record<string, unknown>>(o: T): T; const a = conf({ mode: "dark", n: 1 }); // { mode: string; n: number } — the literals are gone ``` Before TypeScript 5.0 the only cure was to make every caller apply a const assertion at the call site — easy to forget, and forgetting it produces no error, just a useless type. ## What `const T` does Writing the `const` modifier on the type parameter moves that responsibility into the signature: ```ts declare function conf<const T extends Record<string, unknown>>(o: T): T; const b = conf({ mode: "dark", n: 1 }); // { readonly mode: "dark"; readonly n: 1 } ``` The inference now behaves as though the argument expression carried a const assertion: - string, number and boolean literals keep their literal types instead of widening; - object literal properties become `readonly`; - array literals become readonly **tuples** rather than widened arrays. It applies recursively through nested literals, which is what makes it strictly more powerful than constraining the type parameter to a primitive: a constraint can preserve one literal, `const T` preserves a whole literal structure. ## Limit one: only expressions written at the call site This is the limitation candidates most often miss. The modifier influences how the *argument expression* is inferred. If the caller hands over a variable, that variable's type was already computed — and widened — at its own declaration, and there is nothing left to preserve: ```ts declare function conf<const T>(o: T): T; const c1 = conf({ a: "b" }); // { readonly a: "b" } const pre = { a: "b" }; // already { a: string } const c2 = conf(pre); // { a: string } — modifier changed nothing ``` So `const T` improves the ergonomics of inline calls; it cannot retroactively recover precision that was lost at a declaration. ## Limit two: readonly is dropped where the constraint forbids it The modifier does not reject mutable arguments, and it does not force the constraint to accept readonly types. When the inferred readonly form would not satisfy the constraint, the readonly modifier is dropped rather than the call failing. Arrays make this visible, because a `readonly` array is not assignable to a mutable array type: ```ts declare function mut<const T extends string[]>(x: T): T; const m = mut(["a", "b"]); // ["a", "b"] — a mutable tuple declare function ro<const T extends readonly string[]>(x: T): T; const r = ro(["a", "b"]); // readonly ["a", "b"] ``` Object properties behave differently, because a `readonly` property *is* assignable to a mutable one — so `<const T extends { mode: string }>` still infers `{ readonly mode: "dark" }`. The practical guidance: if you want readonly tuples out of a const type parameter, write the constraint as `readonly unknown[]` (or `readonly string[]`, and so on). ## Limit three: it is not runtime immutability The `const` modifier is part of the type layer and is erased with everything else. The emitted JavaScript is unchanged, the argument object is not frozen, and code that reaches the same object through another reference can still mutate it. `readonly` in the inferred type only stops *the checker* from allowing writes through that type. If you need real immutability you still call `Object.freeze` yourself. ## When to reach for it Good fits are helpers whose value is remembering the caller's exact shape: route tables, state-machine definitions, form schemas, column definitions, permission maps — anything where a later `keyof`, indexed access or template-literal type has to see the precise keys and values. The cost is that the inferred types get big and deeply readonly. Those types travel into hovers, error messages and any declaration file you emit, and a deeply readonly inferred type can fail to satisfy a downstream API that expects mutable arrays. Add the modifier where precision is the product; leave it off where the caller just passes data through.

  • Why does `<const T extends string[]>` give a mutable tuple while `<const T extends readonly string[]>` gives a readonly one?
    Because the const modifier never overrides the constraint. The readonly form of the inferred type must still satisfy `T`'s constraint, and a `readonly string[]` is not assignable to `string[]`. Rather than failing the call, the compiler drops the readonly modifier and infers the mutable tuple `["a", "b"]`. Widen the constraint to `readonly string[]` and the readonly tuple survives.
  • When would you prefer constraining the type parameter to a primitive over adding `const`?
    When you only need one literal preserved, not a whole structure. `<T extends string>` keeps a single literal or a union of them, produces small readable types, and adds no readonly modifiers that could clash with a downstream mutable API. Reach for `const T` when nested object or tuple structure must survive — that is precisely what a constraint alone cannot do.
  • Does adding `const` to an existing type parameter break current callers?
    It can. Existing callers now receive deeply readonly, literal-typed values, so code that passed the result into something expecting a mutable array or a widened `string` may stop compiling. It is a source-level breaking change to the inferred types even though the runtime behaviour is identical, so treat it as a semver-relevant edit on a published API.

saying these in an interview costs you the question

  • Thinks const type parameters freeze the object at runtime
  • Expects the modifier to work on a pre-declared variable argument
  • Assumes readonly always survives regardless of the constraint
  • Confuses the modifier with a const assertion written by the caller
  • Believes it rejects mutable arguments

context