Skip to main content

Const assertions (as const) in TypeScript

as const is a TypeScript assertion that tells the compiler to infer the most specific literal types for a value, making every property deeply readonly instead of a widened primitive like string or number.

Theory

TL;DR

  • Without as const: TypeScript infers string, number, string[]. With it: "exact value", 42, readonly ["a", "b"].
  • All properties become readonly. Arrays become fixed-length readonly tuples. Nesting is fully recursive.
  • Use it for static configs, enum-like constants, and React Query cache keys. Skip it for dynamic runtime data.
  • Compile-time only. Zero impact on emitted JavaScript.

Quick example

typescript
// Without as const - widened types const api = { endpoint: "users", version: 1, tags: ["admin", "user"] }; // Type: { endpoint: string; version: number; tags: string[] } // With as const - literal types everywhere const api = { endpoint: "users", version: 1, tags: ["admin", "user"] } as const; // Type: { // readonly endpoint: "users"; // readonly version: 1; // readonly tags: readonly ["admin", "user"]; // }

That second object is now a precise contract. TypeScript blocks any reassignment, catches typos at the call site, and gives autocomplete for every value.

Key difference

TypeScript defaults to widened types so variables stay flexible during development. The literal "users" becomes string because you might reassign it later. as const says: never widen this. Every primitive locks to its exact value, and that transformation recurses through every nested object and array.

When to use

  • Static config objects: as const gives autocomplete and prevents silent typos in property values.
  • Enum alternatives: derive union types from an object's values without maintaining two separate lists.
  • React Query keys: cache invalidation requires exact tuple types, not string[].
  • Function return types: callers get "dark" instead of string when you need literal precision.
  • Skip it when values come from user input, API responses, or change at runtime.

How the compiler handles this

During the contextual typing phase, TypeScript's type checker traverses the AST and replaces primitive inferences with LiteralTypeNode entries. Arrays get wrapped in ReadonlyArray. Objects get readonly wrappers. All of this is erased during compilation: V8 sees a plain JavaScript object. The enforcement exists only inside TypeScript's static analysis pass.

as const does not call Object.freeze. TypeScript will stop you from reassigning config.port at the type level, but JavaScript can still mutate the object at runtime. If you need real runtime safety, combine both.

Common mistakes

Expecting runtime immutability

typescript
const arr = [1, 2, 3] as const; arr.push(4); // TS Error: Property 'push' does not exist on type 'readonly [1, 2, 3]' // The underlying JS array is not frozen - runtime mutation is still possible

Fix: const arr = Object.freeze([1, 2, 3] as const);

Applying as const to a variable that is already widened

typescript
const dynamic = "foo"; // already inferred as string, not "foo" const config = { key: dynamic } as const; // Type: { readonly key: string } - the literal is lost

as const cannot recover a literal that was widened earlier. Define the value inline or in its own const.

Thinking as const narrows function parameter types

typescript
function log(config: { mode: string }) { /* ... */ } log({ mode: "debug" } as const); // Still passes `string` - the function signature is what TypeScript checks

To preserve literals in parameters, use generics: function log<K extends string>(config: { mode: K }).

Real-world usage

  • React Query (TanStack): query keys as readonly ["users"] tuples for exact cache matching.
  • Zod: z.enum(["admin", "editor"] as const) to derive a union from an array without duplication.
  • Redux Toolkit: status: "loading" as const in action payloads to match discriminated union branches.
  • Express: route config objects with method: "GET" as const for type-safe route tables.

Follow-up questions

Q: What is the type of { a: 1, b: 2 } as const?
A: { readonly a: 1; readonly b: 2 }. Both are literal number types, not number.

Q: What is the difference between as const and Readonly<T>?
A: Readonly<T> makes properties readonly but keeps widened types. { x: "a" } wrapped in Readonly still gives x: string. as const does both: readonly and literal types.

Q: Can you apply as const to a function return?
A: Yes. function getConfig() { return { port: 3000 } as const; } - the caller sees readonly port: 3000, not port: number.

Q: How deep does readonly go?
A: Fully recursive. Every nested object and array becomes readonly until you reach primitive values.

Q: How does as const differ from the satisfies operator (TypeScript 4.9+)?
A: satisfies validates a value against an existing type while keeping literal inference. as const narrows to literals without checking against a specific type. You can combine them: { port: 3000 } as const satisfies ServerConfig.

Q: In RTK Query, why do cache tags need as const?
A: Without it, ["Post", post.id] widens to string[]. Tag matching requires exact tuple types. With as const you get readonly ["Post", string], which the matcher can use correctly.

Examples

Deriving a union type from an object

typescript
const STATUS = { PENDING: "pending", ACTIVE: "active", INACTIVE: "inactive", } as const; type Status = typeof STATUS[keyof typeof STATUS]; // "pending" | "active" | "inactive" function updateUser(id: string, status: Status) { // only "pending" | "active" | "inactive" accepted } updateUser("123", STATUS.ACTIVE); // ✅ updateUser("123", "deleted"); // ❌ TS Error

The type updates automatically when you add or rename a value. No need to keep the union definition in sync manually.

React Query cache keys

typescript
const QUERY_KEYS = { users: ["users"] as const, user: (id: string) => ["user", id] as const, } as const; function useUser(id: string) { return useQuery({ queryKey: QUERY_KEYS.user(id) }); } // Invalidation works because the types are exact tuples queryClient.invalidateQueries({ queryKey: QUERY_KEYS.users });

I've seen this break in production: someone removes as const from the query keys file, cache stops invalidating, and it takes an hour to trace back. Exact tuples matter here.

Nested objects and the runtime gap

typescript
const config = { db: { host: "localhost", port: 5432, flags: ["ssl", "compression"], }, } as const; // TypeScript blocks this: config.db.port = 5433; // Error: Cannot assign to 'port' because it is a read-only property // But plain JavaScript does not care: (config as any).db.port = 5433; // No runtime error

For config that genuinely must not change at runtime, add Object.freeze. TypeScript's readonly is a static guarantee. JavaScript's freeze is the runtime one. Different tools, different layers.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?