skip to content

In TypeScript, what type is inferred for `const roles = ['admin', 'user']`, and how does adding `as const` to that array literal change it?

level: juniorimportance: must knowfreq 72%

answer

  1. mutable containers widen their elements
  2. const pins the binding, not contents
  3. three effects at once
  4. array literal becomes a tuple
  5. erased — no runtime freeze

basics

~10 s

TypeScript infers string[] there: a mutable array of widened strings. Adding as const infers readonly ["admin", "user"] instead — a readonly tuple whose elements keep their exact literal types, with fixed length and position.

solid answer

~40 s

By default the compiler assumes an array literal is a mutable container, so it widens each element's literal type to its base type and infers `string[]`. Writing `['admin', 'user'] as const` is a **const assertion**: it tells the checker not to widen anything in that literal. Three things follow. The element types stay as the literals `"admin"` and `"user"`; every position becomes `readonly`; and the array literal becomes a *tuple* rather than an array, so the type is `readonly ["admin", "user"]` with a known length of 2 and `roles[0]` typed as `"admin"`. On an object literal the same assertion makes each property `readonly` and pins its value to a literal type, recursively through nested literals. It is purely a type-level operation — nothing is emitted, and nothing is frozen at runtime.

go deeper

for a junior

Be able to say the two inferred types out loud: string[] without the assertion, readonly ["admin", "user"] with it. Mention that the properties become readonly and the values stay exact.

for a middle

Explain the widening rule that makes the default string[] — a mutable container cannot keep fresh literal types — and that the assertion produces a tuple with a known length and per-index literal types, recursively through nested literals.

for a senior

Show judgment about where it belongs: pin tables of allowed values and fixed tuples, leave real mutable data alone, and be clear that readonly is an author-time constraint you enforce in review, not a runtime safeguard.

for a principal

Own the convention for a codebase: which shared constant tables are asserted and exported, how readonly surfaces ripple into consumer signatures, and the cost of retrofitting the assertion onto widely-used exports once callers already mutate them.

## Why the default is `string[]` When TypeScript infers a type it has to guess how the value will be used. An array literal is a mutable container: you can `push` into it and assign to its indices later. So the compiler *widens* the fresh literal types of the elements to their base types and infers a general array type. ```ts const roles = ['admin', 'user']; // roles: string[] roles.push('guest'); // fine — nothing in the type says otherwise ``` Note that the `const` keyword on the declaration does not help here. `const` pins the *binding* — you cannot reassign `roles` to a different array — but it says nothing about the array's contents, so the contents are still inferred as mutable and widened. The same applies to object literals: ```ts const cfg = { retries: 3 }; // cfg: { retries: number } cfg.retries = 5; // allowed ``` ## What the const assertion changes `as const` is a **const assertion**. Applied to a literal expression, it turns off widening for that whole expression and marks everything it produces as read-only. Three effects, all at once: 1. **Literal types are preserved.** `'admin'` stays `"admin"` instead of becoming `string`; `3` stays `3` instead of becoming `number`; `true` stays `true` instead of `boolean`. 2. **Every property becomes `readonly`.** The checker will reject `cfg.retries = 5`. 3. **Array literals become readonly tuples.** Length and per-position types are fixed. ```ts const roles = ['admin', 'user'] as const; // roles: readonly ["admin", "user"] const cfg = { retries: 3, verbose: true } as const; // cfg: { readonly retries: 3; readonly verbose: true } ``` ## Tuple, not array — why that matters This is the part people miss. `readonly ["admin", "user"]` is a *tuple type*: the compiler knows there are exactly two elements and what each position holds. ```ts const roles = ['admin', 'user'] as const; const first: 'admin' = roles[0]; // index 0 is the literal "admin", not string const n: 2 = roles.length; // length is the literal type 2 ``` Without the assertion, `roles[0]` is `string` and `roles.length` is `number`. That precision is what lets a const-asserted array feed places that demand exact values — argument positions typed as a union of literals, keys of a mapped shape, or a derived union of element types. ## It applies recursively The assertion is not shallow: nested object and array literals inside the asserted expression get the same treatment. ```ts const routes = { home: { path: '/', tags: ['public'] }, } as const; // routes.home.path is "/", routes.home.tags is readonly ["public"] ``` ## Where you are allowed to write it A const assertion may only be applied to a literal expression — a string, number, boolean, array or object literal — or to a reference to an enum member. You cannot write `getRoles() as const` on a function call's result; the compiler rejects it, because there is no literal expression there whose inference it could pin. If you need literal types out of a call, apply the assertion to the literal you pass *in*, or design the function to preserve them. Also worth knowing: a const assertion is an assertion, not a cast that changes the value. It never converts anything and never produces a different runtime object. ## Purely compile-time TypeScript's types are erased. The emitted JavaScript for `const roles = ['admin', 'user'] as const;` is just `const roles = ['admin', 'user'];`. There is no freeze, no proxy, no runtime check and no cost. `readonly` is a promise the checker enforces on code it can see — it is not a runtime guarantee. ## When to reach for it Use `as const` when the *exact values* are part of the meaning: configuration tables, sets of allowed option strings, action-type maps, fixed coordinate or key tuples, and any literal you want to pin so a union can be derived from it later. Skip it when the value is genuinely data that will be mutated or reassigned — you will only fight the `readonly` modifiers for no benefit.

  • Why doesn't declaring the variable with `const` already keep the literal types inside the array?
    `const` is a binding-level guarantee: it stops you reassigning the variable itself. The array object it points to is still mutable, so the compiler widens the element types to `string` to keep those future writes legal. `as const` addresses the contents rather than the binding.
  • What happens if you write `as const` on a function call's return value?
    The compiler rejects it. A const assertion is only valid on a literal expression — string, number, boolean, array or object literal — or on a reference to an enum member. There is no literal there to pin, so if you need literal types from a call you assert the literal you pass in, or type the function to preserve them.
  • Does `as const` change the JavaScript that gets emitted?
    No. Like every type-level construct, the assertion is erased; `['a'] as const` emits `['a']`. It costs nothing at runtime and provides no runtime protection — the `readonly` modifiers only exist while the checker is looking.

saying these in an interview costs you the question

  • Thinks as const calls Object.freeze under the hood
  • Says const on the declaration already prevents element widening
  • Believes as const converts or copies the value
  • Calls the result an array rather than a readonly tuple
  • Thinks readonly properties are enforced at runtime

context