What Is 'as const' and Why Does It Keep Coming Up
Jeff Straney·
TypeScript infers types. That's the feature. You write const x = "hello" and TypeScript knows x
is a string. Mostly, this works exactly as you want it to. But there's a category of inference
where TypeScript's default behavior is technically correct and practically annoying: it widens
literal types to their base types.
"hello" becomes string. ["x", "y"] becomes string[]. { mode: "dark" } becomes
{ mode: string }. The literal information is gone by the time your code tries to use it.
as const turns that off.
Go is still where I reach first for most backend work. The type system is small and does not encourage cleverness. TypeScript went the other direction, and for a while that put me off it. The first time I worked through a codebase with conditional types chained through mapped type utilities I genuinely considered closing the tab and writing a Node script instead.
What brought me back is that TypeScript really is just JavaScript with a build step. The elaborate
machinery is there if you want it, but most of the value comes from the ordinary parts: knowing
what shape an object is, knowing what a function returns, catching the undefined you missed.
as const is that tier of the language. Five minutes to learn. Earns its keep the first afternoon.
What Widening Actually Costs You
Here's the concrete version of the problem. Suppose you have a config object and a function that accepts only specific string values for one of its fields.
type Theme = "light" | "dark" | "system";
function applyTheme(theme: Theme) {
document.body.dataset.theme = theme;
}
const config = {
defaultTheme: "dark",
version: 1,
};
applyTheme(config.defaultTheme);
// Error: Argument of type 'string' is not assignable to parameter of type 'Theme'
The value "dark" is sitting right there in the source. You wrote it. TypeScript can see it. But
by the time config.defaultTheme is referenced, TypeScript has already widened it to string,
because object properties are mutable by default and TypeScript assumes you might change
config.defaultTheme to any other string later.
Add as const:
const config = {
defaultTheme: "dark",
version: 1,
} as const;
applyTheme(config.defaultTheme); // fine
Now config.defaultTheme is typed as "dark", not string. The whole object is also readonly
at every level, which reflects the reality of what a config object usually is.
Tuples
Arrays widen to their element type. That's usually what you want. But sometimes you want a tuple, where position carries meaning.
function setCoordinates(coords: [number, number]) {
const [lat, lng] = coords;
// ...
}
const point = [40.4406, -79.9959];
// TypeScript infers: number[]
setCoordinates(point);
// Error: Argument of type 'number[]' is not assignable to parameter of type '[number, number]'
TypeScript sees [40.4406, -79.9959] and infers number[]. An array of numbers, length unknown,
no positional guarantees. That inference makes sense for arrays you're going to push onto. It
doesn't make sense for a fixed pair of coordinates.
const point = [40.4406, -79.9959] as const;
// Type: readonly [40.4406, -79.9959]
setCoordinates(point); // fine
The literal values are preserved, the length is fixed, and the positions are meaningful. If the function signature changes and the argument order breaks, TypeScript will tell you.
Discriminated Unions
This one is where widening causes the most invisible damage. A discriminated union depends on a tag field holding its exact literal type. When that type widens, the whole discriminant falls apart.
type Action =
| { type: "increment"; amount: number }
| { type: "decrement"; amount: number }
| { type: "reset" };
function reducer(state: number, action: Action): number {
switch (action.type) {
case "increment":
return state + action.amount;
case "decrement":
return state - action.amount;
case "reset":
return 0;
}
}
This works fine when you're constructing actions inline. It breaks when you define them as objects and pass them around:
const resetAction = { type: "reset" };
reducer(0, resetAction);
// Error: Argument of type '{ type: string; }' is not assignable to parameter of type 'Action'
TypeScript widened "reset" to string. The object no longer matches any member of the union
because none of them have type: string. They have specific literal types.
const resetAction = { type: "reset" } as const;
reducer(0, resetAction); // fine
Now resetAction.type is "reset", TypeScript narrows it to the correct union member, and the
exhaustive switch works as intended.
When to Reach for It
The pattern is consistent enough that you develop a feel for it. Reach for as const when:
- You're defining a config object and the string values need to survive through function calls
- You're writing a fixed array where position carries meaning
- You're constructing an action or event object outside the call site where it's consumed
- You want TypeScript's exhaustiveness checking to work correctly on a switch
The mental model is simple: as const tells TypeScript to infer the narrowest possible type for
everything in scope. No widening, no assumptions about mutability, no collapsing of literal types
to their base.
It doesn't change your runtime code. It doesn't add overhead. It's a hint to the compiler that you mean exactly what you wrote.
Most of the time you want inference to do its job without interference. But when you need the
literal to stay literal, as const is the one-word answer.
