Const assertions for literal, immutable types
as const forces the compiler to infer the narrowest possible literal type for an expression instead of widening it to a broad type like string or number[]. The result is more precise types, without writing any extra type definition.
Table of Contents
- 1. What as const does to an expression
- 2. Array literals with as const become tuples, not Array
- 3. Object literals: readonly properties and literal values
- 4. as const with union types instead of enum
- 5. Combining as const and satisfies
- 6. Practical example: configuration objects and action types
- 7. The limits of as const: no runtime guarantee
- 8. as const, Readonly
, and Object.freeze compared - 9. Common mistakes when using as const
- 10. Summary
- 11. FAQ
1. What as const does to an expression
Without a const assertion, TypeScript infers the widest matching type for a literal: let status = "active" gets the type string, not the literal "active". That widening makes sense because a let variable could later be reassigned.
Adding as const after an expression tells the compiler to do exactly the opposite: infer the narrowest possible literal type and implicitly mark every contained property as readonly.
The effect is not limited to single values, it applies recursively to entire expression trees: arrays become readonly tuples with exact element types, objects get readonly properties with literal instead of widened values.
let a = "active"; // type: string
let b = "active" as const; // type: "active"
const status = { code: 200, label: "ok" };
// type: { code: number; label: string }
const statusConst = { code: 200, label: "ok" } as const;
// type: { readonly code: 200; readonly label: "ok" }
2. Array literals with as const become tuples, not Array
A plain array literal gets widened into a generic, mutable array type, such as string[] for a list of color names. The order and length of the elements is completely lost in the process.
With as const, the same literal instead becomes a readonly tuple with exact literal element types at every position. This is particularly useful when a fixed list of allowed values needs to serve as both a runtime value and a type source.
Using typeof together with index access [number], such a tuple can directly produce a union of all contained literal values, without repeating the list a second time as a type.
const themes = ["light", "dark", "system"] as const;
// type: readonly ["light", "dark", "system"]
type Theme = (typeof themes)[number];
// type: "light" | "dark" | "system"
function setTheme(theme: Theme) {
document.documentElement.dataset.theme = theme;
}
3. Object literals: readonly properties and literal values
For objects, as const marks every property as readonly and, if the value is a primitive literal, infers an exact literal type instead of a widened type like number or string.
Nested objects and arrays inside the literal are also captured recursively: a multi-level configuration object becomes fully immutably typed without wrapping every level manually in Readonly.
This works well for constants that act as the single source of truth for allowed values, configuration, or routing tables, because typos in derived types show up immediately as compile errors.
4. as const with union types instead of enum
Many teams now use an object with as const instead of an enum to define a fixed set of values. The advantage: no extra runtime construct is generated the way numeric enums do, just a plain object with literal values.
From that object, both a union type of the keys and of the values can be derived via typeof and keyof, which in practice tends to be more flexible than a classic TypeScript enum with its own special-case rules.
Another advantage over string enums: the values remain plain, unnamed string literals that compare cleanly against external APIs, JSON payloads, or database values, without going through an enum detour first.
const OrderStatus = {
Pending: "pending",
Shipped: "shipped",
Delivered: "delivered",
} as const;
type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus];
// type: "pending" | "shipped" | "delivered"
function isFinal(status: OrderStatus): boolean {
return status === OrderStatus.Delivered;
}
5. Combining as const and satisfies
as const alone produces the narrowest possible type, but it does not check whether that type actually matches an expected shape. That is exactly what the satisfies operator adds: it validates an expression against a target type without widening the inferred literal type.
The combination { ... } satisfies Record is not valid in that order, which is why satisfies typically comes right after the object literal and as const is dropped once a satisfies constraint is already checking the structure.
In practice, satisfies replaces many cases where as const alone used to be applied to configuration objects, because it additionally raises an error the moment a key is missing or a value does not match the expected type.
type RouteConfig = { path: string; auth: boolean };
const routes = {
dashboard: { path: "/dashboard", auth: true },
login: { path: "/login", auth: false },
} satisfies Record<string, RouteConfig>;
// routes.dashboard.path stays the literal "/dashboard", not just string
6. Practical example: configuration objects and action types
In state management libraries like Redux, or in hand-rolled reducer implementations, as const is commonly used to produce action objects with a literal type field that the compiler can evaluate for discriminated unions inside a switch statement.
Without as const, the type field would widen to string, which means TypeScript could no longer perform exhaustive checking in the switch or infer specific payload types per case.
API client configurations with fixed endpoint paths benefit too: a single object defined with as const serves simultaneously as the runtime value used for HTTP calls and as the type source for every allowed route.
function increment(amount: number) {
return { type: "increment", amount } as const;
}
function reset() {
return { type: "reset" } as const;
}
type Action = ReturnType<typeof increment> | ReturnType<typeof reset>;
function reducer(state: number, action: Action): number {
switch (action.type) {
case "increment":
return state + action.amount;
case "reset":
return 0;
}
}
7. The limits of as const: no runtime guarantee
as const is purely a compile-time construct. At runtime, the object or array remains a perfectly ordinary, mutable JavaScript value. Only the compiler refuses assignments to properties marked as readonly.
Anyone who needs actual runtime protection against mutation, for example because an object could be changed by non-TypeScript code, additionally needs Object.freeze(), which genuinely prevents mutation at runtime, or at least throws in strict mode.
In practice, compile-time-only protection is enough for most internal use cases, because mutation attempts by your own TypeScript code already surface as errors during development, long before they could reach production.
8. as const, Readonly, and Object.freeze compared
Readonly<T> converts an existing type into a readonly variant, but only one level deep, and it does not automatically narrow literal values, whereas as const does both at once, directly on the expression itself.
Object.freeze() is purely runtime behavior with no effect on the types TypeScript infers, unless it is combined with a generic return type that also carries the readonly semantics into the type layer.
In production code, all three are frequently combined: as const for literal, immutable configuration in source, Readonly<T> for function parameters meant to prevent mutation, and Object.freeze() wherever actual runtime protection is required.
9. Common mistakes when using as const
A frequent mistake is applying as const to a value that is then still declared with let: the literal, readonly type then blocks any later assignment, even though let would otherwise allow it.
A second pitfall involves contextual typing: when an object created with as const is passed to a function expecting a wider, mutable type, TypeScript can reject the readonly type, since a readonly structure is not automatically assignable to a mutable one.
In these cases, an explicit type annotation at the target site helps, or deliberately dropping as const in favor of satisfies, which checks the structure without blocking later assignability to mutable types.
| Aspect | as const | Readonly |
Object.freeze() |
|---|---|---|---|
| Applies to | An expression, right at its definition | An existing type | A runtime object |
| Depth | Recursive across the whole expression | One level deep only | One level deep only (without extra code) |
| Literal types | Yes, prevents widening | No, only changes mutability | No, purely a runtime effect |
| Runtime effect | None | None | Actually prevents mutation |
| Typical use | Constant configuration, tuples, action types | Function parameters, API signatures | Security-sensitive, immutable objects |
Mironsoft
TypeScript migration, type safety, and team onboarding
A JavaScript codebase without type safety, but no time for a full migration?
We migrate existing JavaScript projects to TypeScript step by step, set up strict compiler settings cleanly, and bring teams to the same type-safety level with code reviews and style guides.
Migration Roadmap
Plan and execute a gradual JS-to-TS migration without big-bang risk.
Strict Mode Rollout
Set up tsconfig.json, ESLint rules, and CI checks for lasting type safety.
Team Onboarding
Bring developers up to speed on TypeScript best practices with workshops and reviews.
10. Summary
as const
Literal types
Narrowest inferred type, no widening
Recursive readonly
Applies to nested arrays and objects
Compile-time only
No runtime guarantee against mutation
enum alternative
Object plus as const instead of enum