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 infersstring,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
// 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 constgives 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 ofstringwhen 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
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 possibleFix: const arr = Object.freeze([1, 2, 3] as const);
Applying as const to a variable that is already widened
const dynamic = "foo"; // already inferred as string, not "foo"
const config = { key: dynamic } as const;
// Type: { readonly key: string } - the literal is lostas 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
function log(config: { mode: string }) { /* ... */ }
log({ mode: "debug" } as const);
// Still passes `string` - the function signature is what TypeScript checksTo 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 constin action payloads to match discriminated union branches. - Express: route config objects with
method: "GET" as constfor 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
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 ErrorThe type updates automatically when you add or rename a value. No need to keep the union definition in sync manually.
React Query cache keys
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
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 errorFor 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 readyA concise answer to help you respond confidently on this topic during an interview.