skip to content

const Assertions (as const)

What `as const` actually does to an object or array literal: freezes the type to its literal values, marks everything readonly, and turns arrays into readonly tuples. A favorite question because the answer explains how people derive union types from a plain config object without an enum.

part ofTypeScriptoverview, primer and where to startread it →
on this pageshow

questions

4

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

open as a page

In TypeScript, given `const STATUS = { idle: 'idle', busy: 'busy' };`, how do you derive a union type of its values, and what has to be true of the object for that to work?

level: middleimportance: must knowfreq 60%

basics

~20 s

Assert the object with as const so its values keep their literal types, then take an indexed access over its keys: type Status = (typeof STATUS)[keyof typeof STATUS], which is "idle" | "busy". Without as const the values widen and the union collapses to string.

open as a page

In TypeScript, what is the difference between writing `as const` on an object literal and writing `satisfies SomeType` after it, and when would you use both together?

level: middleimportance: should knowfreq 47%

basics

~20 s

They do different jobs. as const pins inference: literal types are kept and everything becomes readonly. satisfies checks the literal against a type without replacing the inferred type. Written together, as const satisfies T both pins the values and validates the shape.

open as a page

A shared configuration object in a TypeScript service is declared with `as const`. A teammate says this makes it immutable. Is that accurate, and how would you actually guarantee the object is not mutated at runtime?

level: seniorimportance: should knowfreq 40%

basics

~20 s

No. A const assertion is a compile-time constraint that is erased at emit — it produces no runtime protection. Direct writes are rejected by the checker, but any alias typed as mutable, any cast, or any untyped consumer can still change the object. Runtime immutability needs a runtime mechanism.

open as a page