My Reducer's Switch Stopped Being Exhaustive. as const Fixed It.
Object and array literals widen to `string` and `number` the moment you write them, which quietly breaks discriminated unions and exhaustive switch checks. `as const` keeps the literal types — but it also freezes the value, which bites in its own way.

The PR review catches it at the worst possible moment — the day after it shipped. A teammate added a 'reset' action to the cart reducer, ran the tests, watched them pass, and merged. The switch statement never ran the new branch. Not an error. Not a warning. The build was green the whole time.
Here's the reducer, trimmed down to the shape that matters:
action.type is typed as string. TypeScript can check a switch against a union of specific values and tell you when a case is missing. It cannot check a switch against all possible strings — there's no way to be "exhaustive" over an infinite type. So the compiler does the only honest thing: it says nothing.
The bug wasn't the missing case. The bug was string — and it was there before anyone touched the reducer.
Nobody typed action.type: string on purpose. It came from the action creators:
That's the part that surprises people. reset is a const-worthy, never-reassigned function returning a single literal. You'd expect { type: 'reset' } to stay exactly that specific. It doesn't — and the reason is really about mutability, not constants.
A const binding to a primitive can't be reassigned, so TypeScript has no reason to generalize — the literal type is the only thing it will ever hold. A property inside an object is a different story: config is const, but config.status isn't. TypeScript widens object and array members to their general type — string, number, boolean — because it's inferring a type that has to tolerate future writes, not just describe the value in front of it. Same rule for arrays:
That widening is usually what you want. It's only a problem when the whole point of the value was to be one of a fixed, known set — an action's type, a theme name, an HTTP method you're about to switch on.
Before you scroll — what do you think turns string back into the literal 'idle'?
as const is a type assertion that does the opposite of widening: it takes the literal, narrowest type of exactly what you wrote, and makes every property (recursively) readonly so there's no future write to protect against.
Apply it to the action creators and the whole chain fixes itself:
action.type inside the reducer is now 'add' | 'remove' | 'reset' instead of string — a real, finite union. Switch over it, and TypeScript can finally do the one thing it couldn't before:
Delete the 'reset' case and action reaching the default branch is no longer never — it's { type: 'reset' }, which doesn't satisfy assertNever's never parameter. TypeScript flags it at the call site, at compile time, before a reviewer has to catch it by reading carefully. That's the whole fix: one three-letter suffix, in the three functions that build the actions.
Runs right in your browser — poke at it and watch the concept react live.
Here's where it gets uglier, and where most explanations of as const stop too early. as const didn't just lock in the literal type — it made the value readonly, all the way down. That's not a side effect you can opt out of; it's the mechanism.
A readonly tuple has no mutating methods at all — no push, pop, splice, or index assignment. That's usually fine; you reached for as const because you wanted a fixed set, not a growable list. But it surfaces somewhere you won't expect the first time:
logAllowedMethods never mutates its argument, but its signature promises TypeScript it's allowed to. TypeScript can't verify that promise against a value it knows is frozen, so it refuses the call outright — correctly, because somewhere else methods.push(...) really would be legal against that same signature. Two ways out, and they mean different things:
Widen the parameter if the function is well-behaved and just reads. Copy the array if you don't control the function. Either is one line; the trap is not knowing which bug you're looking at when the red squiggle shows up two files away from the as const that caused it.
- Action/event objects — reducers, event buses, anywhere a
typeorkindfield drives a switch. This is the highest-leverage use; it's the difference between a real exhaustiveness check and a decorative one. - Fixed option lists —
const THEMES = ['light', 'dark', 'system'] as const;thentype Theme = (typeof THEMES)[number];gives you the union'light' | 'dark' | 'system'for free, generated from the one array you'll actually iterate to build a<select>. - Enum-free constant maps —
const Role = { ADMIN: 'admin', EDITOR: 'editor' } as const;reads better than a TypeScriptenumfor most teams and gives you(typeof Role)[keyof typeof Role]as the type. - Not for values you're going to mutate. If you need to
.push()into it later,as constis actively the wrong tool — reach for an explicit type annotation instead.
And if you're already validating the shape against an interface, you may not need as const at all — satisfies checks a value against a type and keeps its inferred literals, in one step, without the readonly. The two solve overlapping problems from opposite directions: as const narrows by removing information you didn't ask TypeScript to track (mutability); satisfies narrows by not throwing away information you already had. Worth knowing both before reaching for either.
The missing 'reset' case made it to production because string doesn't have gaps to find. as const is how you tell TypeScript the value in front of it is the only value it will ever be — so when you miss one, the compiler misses it too, loudly, before a reviewer has to.
Where has a widened type let a real bug through for you — and did you reach for as const, or something else?
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.