as const: The Assertion That Unlocks Literal Types
Photo: Unsplash
By default, TypeScript is an optimist about how you intend to use your values. Write let mode = "dark" and it infers string, because it assumes you might reassign it later. Write an object and it assumes every property is mutable and broadly typed. That optimism is usually helpful, but it quietly throws away information you often want to keep: the fact that mode is specifically "dark", not just any string.
The const assertion, written as const, flips that default. It tells the compiler to infer the narrowest, most literal type possible and to make the whole structure deeply readonly. That one small annotation is the difference between a value the compiler treats as loose data and a value it treats as an exact, frozen constant. Once you see what it unlocks, you start reaching for it constantly.
What widening costs you
To appreciate as const, look at what the default inference does to a configuration-style object.
const config = {
env: "production",
retries: 3,
features: ["search", "billing"],
};
// Inferred as:
// {
// env: string;
// retries: number;
// features: string[];
// }Every literal has been widened. env is string, not "production". features is a mutable string[], so the compiler will happily let you push onto it. This is fine if config is genuinely mutable runtime state, but for a constant it discards exactly the precision you would want when, say, switching on config.env. The widening rules are part of how the compiler handles const versus let, and the runtime half is described in the MDN const reference.
Adding as const
Append as const and the same object becomes a precise, readonly literal.
const config = {
env: "production",
retries: 3,
features: ["search", "billing"],
} as const;
// Inferred as:
// {
// readonly env: "production";
// readonly retries: 3;
// readonly features: readonly ["search", "billing"];
// }Now env is the literal "production", retries is 3, and features is a readonly tuple of two specific strings. Every property is readonly, recursively, so the compiler will reject config.features.push(...) or config.env = "staging". The value is now as precise at the type level as it is at runtime. The official explanation lives in the TypeScript handbook section on const assertions.
as const is not a type cast in the dangerous sense. It does not let you lie about a value the way as SomeType can. It only instructs the compiler to infer the most specific type the value already has, then freeze it. There is no runtime effect whatsoever.
Deriving unions from values
The most common reason to reach for as const is to turn a single array or object into the source of truth for a union type. Instead of declaring a union and a matching array separately and keeping them in sync, you declare the data once and derive the type.
const ROLES = ["admin", "editor", "viewer"] as const;
// Index into the readonly tuple to get the union of its members.
type Role = (typeof ROLES)[number]; // "admin" | "editor" | "viewer"
function setRole(role: Role) { /* ... */ }
setRole("editor"); // ok
setRole("guest"); // Error: not assignable to RoleWithout as const, ROLES would be string[] and (typeof ROLES)[number] would be string, giving you no safety at all. The const assertion is what makes the array a tuple of literals, which is what makes the derived union meaningful. You now have a single list that drives both your runtime iteration and your compile-time type, and they cannot drift apart.
Literal types for narrowing and discriminants
as const is also how you get clean literal types in places that feed narrowing, like the action objects in a reducer or the discriminant of a union. A function returning an object literal widens by default, which breaks discriminated-union narrowing.
// Without as const, `type` widens to string and narrowing fails.
function loaded(data: string) {
return { type: "loaded", data } as const;
}
const action = loaded("hello");
// action.type is the literal "loaded", usable as a discriminant
if (action.type === "loaded") {
action.data.toUpperCase();
}Because type is the literal "loaded" rather than string, this object slots into a discriminated union and narrows correctly. This is why action creators and event factories so often end with as const: it preserves the literal tags that the consuming switch statements depend on.
Common pitfalls
A couple of sharp edges are worth knowing. First, as const makes everything readonly, which means you cannot pass the value to a function expecting a mutable array or object without a type error. That is usually a signal worth heeding, but when you need to opt out, copy the value or widen the parameter type. Second, as const only deep-freezes at the type level; it does not call Object.freeze, so a stray as any can still mutate the underlying object at runtime. The protection is a compile-time guarantee, not a runtime lock.
Finally, as const pairs beautifully with the satisfies operator. Use as const to lock the literals, then satisfies SomeType to verify the frozen value matches a contract, getting precision and validation together. The two features are documented side by side in the TypeScript docs and compose without friction.
Takeaways
- By default TypeScript widens literals to
stringandnumberand treats structures as mutable. as constinfers the narrowest literal types and makes the whole structure deeplyreadonly.- Derive unions from data with
const ARR = [...] as constandtype T = (typeof ARR)[number]. - It preserves literal discriminants, so action creators and event factories narrow correctly.
- It is type-level only: no
Object.freeze, no runtime cost, and it cannot lie about a value. - Combine
as const satisfies Tto get readonly literals that are also validated against a contract.

