[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-typescript-as-const-assertions":44,"search-suggestions":60,"quiz-article-typescript-as-const-assertions":108,"path-membership-typescript-as-const-assertions":320},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,{"id":45,"slug":46,"kind":47,"title":48,"description":49,"config":50,"verticalId":5,"vertical":55,"course":52,"_count":56,"access":57,"attempts":59,"questionCount":51},"01a157ee-b214-7367-868b-3053022fe4b5","typescript-as-const-assertions","PRACTICE_QUIZ","as const: literal types, readonly tuples, and exhaustive switches","Eight questions on why object and array literals widen, what as const actually does to the type, and the readonly trade-off that comes with it.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},8,null,70,"AFTER_SUBMIT",{"slug":6,"name":7},{"questions":51},{"allowed":17,"reason":58},"FREE",[],[61,65,69,73,77,81,85,89,93,97,101,104],{"slug":62,"name":63,"articles":64},"webdev","Webdev",152,{"slug":66,"name":67,"articles":68},"javascript","Javascript",126,{"slug":70,"name":71,"articles":72},"frontend","Frontend",88,{"slug":74,"name":75,"articles":76},"tutorial","Tutorial",59,{"slug":78,"name":79,"articles":80},"css","Css",45,{"slug":82,"name":83,"articles":84},"performance","Performance",25,{"slug":86,"name":87,"articles":88},"typescript","Typescript",22,{"slug":90,"name":91,"articles":92},"react","React",19,{"slug":94,"name":95,"articles":96},"browser","Browser",14,{"slug":98,"name":99,"articles":100},"programming","Programming",13,{"slug":102,"name":103,"articles":100},"node","Node",{"slug":105,"name":106,"articles":107},"accessibility","Accessibility",10,{"id":109,"slug":46,"title":110,"subtitle":52,"excerpt":111,"coverUrl":112,"locale":13,"readingMinutes":113,"publishedAt":114,"viewCount":115,"likeCount":19,"commentCount":19,"author":116,"vertical":121,"topic":122,"tags":124,"_count":129,"playground":131,"body":133,"bodyMd":306,"seo":307,"translationGroupId":310,"series":52,"podcastUrl":52,"verticalId":5,"thread":311,"assessments":313,"translations":316,"quiz":318},"01a157ee-b1c1-7777-8aa6-ef7aa4ef6bd3","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.","\u002Fmedia\u002Fcovers\u002Ftypescript-as-const-assertions.png",7,"2026-10-25T19:53:06.537Z",28,{"id":117,"name":118,"username":119,"avatarUrl":52,"headline":120},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":86,"name":123},"TypeScript",[125,126,127,128],{"slug":86,"name":87,"color":52},{"slug":66,"name":67,"color":52},{"slug":62,"name":63,"color":52},{"slug":98,"name":99,"color":52},{"assessments":130},1,{"slug":46,"title":132},"as const — catch the missing case, not the bug report",{"blocks":134,"version":130},[135,139,142,148,151,154,158,161,165,168,172,175,179,182,185,189,192,196,199,203,206,210,213,216,219,222,225,228,231,234,238,241,245,248,252,255,259,267,270,273,276,279,282,285,288,291,294,297,300],{"id":136,"html":137,"type":138},"b1","\u003Cp>The PR review catches it at the worst possible moment — the day after it shipped. A teammate added a \u003Ccode>&#39;reset&#39;\u003C\u002Fcode> 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.\u003C\u002Fp>","paragraph",{"id":140,"html":141,"type":138},"b2","\u003Cp>Here&#39;s the reducer, trimmed down to the shape that matters:\u003C\u002Fp>",{"id":143,"code":144,"type":145,"language":146,"highlight":147},"b3","function cartReducer(state: CartState, action: { type: string; payload?: unknown }) {\n  switch (action.type) {\n    case 'add':\n      return { ...state, items: [...state.items, action.payload] };\n    case 'remove':\n      return { ...state, items: state.items.filter((i) => i.id !== action.payload) };\n    \u002F\u002F 'reset' was added here as an afterthought, then deleted by accident\n    \u002F\u002F during a rebase. TypeScript never noticed either way.\n  }\n  return state;\n}","code","ts",[],{"id":149,"html":150,"type":138},"b4","\u003Cp>\u003Ccode>action.type\u003C\u002Fcode> is typed as \u003Ccode>string\u003C\u002Fcode>. 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 \u003Cem>all possible strings\u003C\u002Fem> — there&#39;s no way to be &quot;exhaustive&quot; over an infinite type. So the compiler does the only honest thing: it says nothing.\u003C\u002Fp>",{"id":152,"html":153,"type":138},"b5","\u003Cp>The bug wasn&#39;t the missing case. The bug was \u003Ccode>string\u003C\u002Fcode> — and it was there before anyone touched the reducer.\u003C\u002Fp>",{"id":155,"html":156,"text":156,"type":157,"level":31},"b6","Where the type actually came from","heading",{"id":159,"html":160,"type":138},"b7","\u003Cp>Nobody typed \u003Ccode>action.type: string\u003C\u002Fcode> on purpose. It came from the action creators:\u003C\u002Fp>",{"id":162,"code":163,"type":145,"language":146,"highlight":164},"b8","function reset() {\n  return { type: 'reset' };\n}\n\nreset(); \u002F\u002F return type: { type: string }",[],{"id":166,"html":167,"type":138},"b9","\u003Cp>That&#39;s the part that surprises people. \u003Ccode>reset\u003C\u002Fcode> is a \u003Ccode>const\u003C\u002Fcode>-worthy, never-reassigned function returning a single literal. You&#39;d expect \u003Ccode>{ type: &#39;reset&#39; }\u003C\u002Fcode> to stay exactly that specific. It doesn&#39;t — and the reason is really about mutability, not constants.\u003C\u002Fp>",{"id":169,"code":170,"type":145,"language":146,"highlight":171},"b10","const status = 'idle';\n\u002F\u002F type: 'idle' — status can never change, so TypeScript keeps the literal\n\nconst config = { status: 'idle' };\n\u002F\u002F config.status type: string — config.status CAN be reassigned\n\u002F\u002F (config.status = 'loading' compiles fine), so TypeScript widens\n\u002F\u002F the property to the general type that covers anything you might assign",[],{"id":173,"html":174,"type":138},"b11","\u003Cp>A \u003Ccode>const\u003C\u002Fcode> binding to a primitive can&#39;t be reassigned, so TypeScript has no reason to generalize — the literal type is the only thing it will ever hold. A property \u003Cem>inside\u003C\u002Fem> an object is a different story: \u003Ccode>config\u003C\u002Fcode> is \u003Ccode>const\u003C\u002Fcode>, but \u003Ccode>config.status\u003C\u002Fcode> isn&#39;t. TypeScript widens object and array members to their general type — \u003Ccode>string\u003C\u002Fcode>, \u003Ccode>number\u003C\u002Fcode>, \u003Ccode>boolean\u003C\u002Fcode> — because it&#39;s inferring a type that has to tolerate future writes, not just describe the value in front of it. Same rule for arrays:\u003C\u002Fp>",{"id":176,"code":177,"type":145,"language":146,"highlight":178},"b12","const methods = ['GET', 'POST'];\n\u002F\u002F type: string[] — any string could get pushed in later",[],{"id":180,"html":181,"type":138},"b13","\u003Cp>That widening is usually what you want. It&#39;s only a problem when the whole point of the value was to be one of a fixed, known set — an action&#39;s \u003Ccode>type\u003C\u002Fcode>, a theme name, an HTTP method you&#39;re about to switch on.\u003C\u002Fp>",{"id":183,"html":184,"type":138},"b14","\u003Cp>Before you scroll — what do you think turns \u003Ccode>string\u003C\u002Fcode> back into the literal \u003Ccode>&#39;idle&#39;\u003C\u002Fcode>?\u003C\u002Fp>",{"id":186,"html":187,"text":188,"type":157,"level":31},"b15","\u003Ccode>as const\u003C\u002Fcode> tells TypeScript to stop generalizing","as const tells TypeScript to stop generalizing",{"id":190,"html":191,"type":138},"b16","\u003Cp>\u003Ccode>as const\u003C\u002Fcode> 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) \u003Ccode>readonly\u003C\u002Fcode> so there&#39;s no future write to protect against.\u003C\u002Fp>",{"id":193,"code":194,"type":145,"language":146,"highlight":195},"b17","const config = { status: 'idle' } as const;\n\u002F\u002F config.status type: 'idle'  (readonly)\n\nconst methods = ['GET', 'POST'] as const;\n\u002F\u002F type: readonly ['GET', 'POST']  — a fixed-length, literal tuple, not string[]",[],{"id":197,"html":198,"type":138},"b18","\u003Cp>Apply it to the action creators and the whole chain fixes itself:\u003C\u002Fp>",{"id":200,"code":201,"type":145,"language":146,"highlight":202},"b19","function reset() {\n  return { type: 'reset' } as const;\n}\nfunction add(payload: Item) {\n  return { type: 'add', payload } as const;\n}\nfunction remove(id: string) {\n  return { type: 'remove', payload: id } as const;\n}\n\ntype CartAction = ReturnType\u003Ctypeof reset> | ReturnType\u003Ctypeof add> | ReturnType\u003Ctypeof remove>;\n\u002F\u002F { type: 'reset' } | { type: 'add'; payload: Item } | { type: 'remove'; payload: string }",[],{"id":204,"html":205,"type":138},"b20","\u003Cp>\u003Ccode>action.type\u003C\u002Fcode> inside the reducer is now \u003Ccode>&#39;add&#39; | &#39;remove&#39; | &#39;reset&#39;\u003C\u002Fcode> instead of \u003Ccode>string\u003C\u002Fcode> — a real, finite union. Switch over it, and TypeScript can finally do the one thing it couldn&#39;t before:\u003C\u002Fp>",{"id":207,"code":208,"type":145,"language":146,"highlight":209},"b21","function cartReducer(state: CartState, action: CartAction): CartState {\n  switch (action.type) {\n    case 'add':\n      return { ...state, items: [...state.items, action.payload] };\n    case 'remove':\n      return { ...state, items: state.items.filter((i) => i.id !== action.payload) };\n    default:\n      return assertNever(action); \u002F\u002F ← now a compile error if a case is missing\n  }\n}\n\nfunction assertNever(x: never): never {\n  throw new Error(`Unhandled action: ${JSON.stringify(x)}`);\n}",[],{"id":211,"html":212,"type":138},"b22","\u003Cp>Delete the \u003Ccode>&#39;reset&#39;\u003C\u002Fcode> case and \u003Ccode>action\u003C\u002Fcode> reaching the \u003Ccode>default\u003C\u002Fcode> branch is no longer \u003Ccode>never\u003C\u002Fcode> — it&#39;s \u003Ccode>{ type: &#39;reset&#39; }\u003C\u002Fcode>, which doesn&#39;t satisfy \u003Ccode>assertNever\u003C\u002Fcode>&#39;s \u003Ccode>never\u003C\u002Fcode> parameter. TypeScript flags it at the call site, at compile time, before a reviewer has to catch it by reading carefully. That&#39;s the whole fix: one three-letter suffix, in the three functions that build the actions.\u003C\u002Fp>",{"id":214,"html":215,"type":138},"b23","\u003C!-- playground:start -->",{"id":217,"html":218,"text":218,"type":157,"level":31},"b24","🎮 Try it yourself",{"id":220,"html":221,"type":138},"b25","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ftypescript-as-const-assertions\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":223,"html":224,"type":138},"b26","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":226,"html":227,"type":138},"b27","\u003C!-- playground:end -->",{"id":229,"html":230,"text":230,"type":157,"level":31},"b28","The part that bites back",{"id":232,"html":233,"type":138},"b29","\u003Cp>Here&#39;s where it gets uglier, and where most explanations of \u003Ccode>as const\u003C\u002Fcode> stop too early. \u003Ccode>as const\u003C\u002Fcode> didn&#39;t just lock in the literal type — it made the value \u003Ccode>readonly\u003C\u002Fcode>, all the way down. That&#39;s not a side effect you can opt out of; it&#39;s the mechanism.\u003C\u002Fp>",{"id":235,"code":236,"type":145,"language":146,"highlight":237},"b30","const METHODS = ['GET', 'POST'] as const;\n\nMETHODS.push('PUT');\n\u002F\u002F Error: Property 'push' does not exist on type 'readonly [\"GET\", \"POST\"]'",[],{"id":239,"html":240,"type":138},"b31","\u003Cp>A \u003Ccode>readonly\u003C\u002Fcode> tuple has no mutating methods at all — no \u003Ccode>push\u003C\u002Fcode>, \u003Ccode>pop\u003C\u002Fcode>, \u003Ccode>splice\u003C\u002Fcode>, or index assignment. That&#39;s usually fine; you reached for \u003Ccode>as const\u003C\u002Fcode> because you wanted a fixed set, not a growable list. But it surfaces somewhere you won&#39;t expect the first time:\u003C\u002Fp>",{"id":242,"code":243,"type":145,"language":146,"highlight":244},"b32","function logAllowedMethods(methods: string[]) {\n  console.log(methods.join(', '));\n}\n\nlogAllowedMethods(METHODS);\n\u002F\u002F Error: Argument of type 'readonly [\"GET\", \"POST\"]' is not assignable\n\u002F\u002F to parameter of type 'string[]'. The type 'readonly [\"GET\", \"POST\"]'\n\u002F\u002F is 'readonly' and cannot be assigned to the mutable type 'string[]'.",[],{"id":246,"html":247,"type":138},"b33","\u003Cp>\u003Ccode>logAllowedMethods\u003C\u002Fcode> never mutates its argument, but its signature \u003Cem>promises\u003C\u002Fem> TypeScript it&#39;s allowed to. TypeScript can&#39;t verify that promise against a value it knows is frozen, so it refuses the call outright — correctly, because somewhere else \u003Ccode>methods.push(...)\u003C\u002Fcode> really would be legal against that same signature. Two ways out, and they mean different things:\u003C\u002Fp>",{"id":249,"code":250,"type":145,"language":146,"highlight":251},"b34","function logAllowedMethods(methods: readonly string[]) { \u002F* ... *\u002F }\nlogAllowedMethods(METHODS); \u002F\u002F fine — the parameter now admits it won't mutate\n\n\u002F\u002F or, if the function genuinely can't change:\nlogAllowedMethods([...METHODS]); \u002F\u002F fine — a fresh, mutable copy",[],{"id":253,"html":254,"type":138},"b35","\u003Cp>Widen the parameter if the function is well-behaved and just reads. Copy the array if you don&#39;t control the function. Either is one line; the trap is not knowing which bug you&#39;re looking at when the red squiggle shows up two files away from the \u003Ccode>as const\u003C\u002Fcode> that caused it.\u003C\u002Fp>",{"id":256,"html":257,"text":258,"type":157,"level":31},"b36","Where it&#39;s worth reaching for","Where it's worth reaching for",{"id":260,"type":261,"items":262,"ordered":18},"b37","list",[263,264,265,266],"\u003Cstrong>Action\u002Fevent objects\u003C\u002Fstrong> — reducers, event buses, anywhere a \u003Ccode>type\u003C\u002Fcode> or \u003Ccode>kind\u003C\u002Fcode> field drives a switch. This is the highest-leverage use; it&#39;s the difference between a real exhaustiveness check and a decorative one.","\u003Cstrong>Fixed option lists\u003C\u002Fstrong> — \u003Ccode>const THEMES = [&#39;light&#39;, &#39;dark&#39;, &#39;system&#39;] as const;\u003C\u002Fcode> then \u003Ccode>type Theme = (typeof THEMES)[number];\u003C\u002Fcode> gives you the union \u003Ccode>&#39;light&#39; | &#39;dark&#39; | &#39;system&#39;\u003C\u002Fcode> for free, generated from the one array you&#39;ll actually iterate to build a \u003Ccode>&lt;select&gt;\u003C\u002Fcode>.","\u003Cstrong>Enum-free constant maps\u003C\u002Fstrong> — \u003Ccode>const Role = { ADMIN: &#39;admin&#39;, EDITOR: &#39;editor&#39; } as const;\u003C\u002Fcode> reads better than a TypeScript \u003Ccode>enum\u003C\u002Fcode> for most teams and gives you \u003Ccode>(typeof Role)[keyof typeof Role]\u003C\u002Fcode> as the type.","\u003Cstrong>Not\u003C\u002Fstrong> for values you&#39;re going to mutate. If you need to \u003Ccode>.push()\u003C\u002Fcode> into it later, \u003Ccode>as const\u003C\u002Fcode> is actively the wrong tool — reach for an explicit type annotation instead.",{"id":268,"html":269,"type":138},"b38","\u003Cp>And if you&#39;re already validating the shape against an interface, you may not need \u003Ccode>as const\u003C\u002Fcode> at all — \u003Ccode>satisfies\u003C\u002Fcode> checks a value against a type \u003Cem>and\u003C\u002Fem> keeps its inferred literals, in one step, without the \u003Ccode>readonly\u003C\u002Fcode>. The two solve overlapping problems from opposite directions: \u003Ccode>as const\u003C\u002Fcode> narrows by removing information you didn&#39;t ask TypeScript to track (mutability); \u003Ccode>satisfies\u003C\u002Fcode> narrows by not throwing away information you already had. Worth knowing both before reaching for either.\u003C\u002Fp>",{"id":271,"html":272,"type":138},"b39","\u003Cp>The missing \u003Ccode>&#39;reset&#39;\u003C\u002Fcode> case made it to production because \u003Ccode>string\u003C\u002Fcode> doesn&#39;t have gaps to find. \u003Ccode>as const\u003C\u002Fcode> 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.\u003C\u002Fp>",{"id":274,"html":275,"type":138},"b40","\u003Cp>Where has a widened type let a real bug through for you — and did you reach for \u003Ccode>as const\u003C\u002Fcode>, or something else?\u003C\u002Fp>",{"id":277,"html":278,"type":138},"b41","\u003C!-- quiz:start -->",{"id":280,"html":281,"text":281,"type":157,"level":31},"b42","🧠 Test yourself",{"id":283,"html":284,"type":138},"b43","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ftypescript-as-const-assertions\u002Fquiz\">Take the 8-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":286,"html":287,"type":138},"b44","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":289,"html":290,"type":138},"b45","\u003C!-- quiz:end -->",{"id":292,"type":293},"b46","divider",{"id":295,"html":296,"type":138},"b47","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":298,"html":299,"type":138},"b48","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":301,"type":261,"items":302,"ordered":18},"b49",[303,304,305],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","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.\n\nHere's the reducer, trimmed down to the shape that matters:\n\n```ts\nfunction cartReducer(state: CartState, action: { type: string; payload?: unknown }) {\n  switch (action.type) {\n    case 'add':\n      return { ...state, items: [...state.items, action.payload] };\n    case 'remove':\n      return { ...state, items: state.items.filter((i) => i.id !== action.payload) };\n    \u002F\u002F 'reset' was added here as an afterthought, then deleted by accident\n    \u002F\u002F during a rebase. TypeScript never noticed either way.\n  }\n  return state;\n}\n```\n\n`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.\n\nThe bug wasn't the missing case. The bug was `string` — and it was there before anyone touched the reducer.\n\n## Where the type actually came from\n\nNobody typed `action.type: string` on purpose. It came from the action creators:\n\n```ts\nfunction reset() {\n  return { type: 'reset' };\n}\n\nreset(); \u002F\u002F return type: { type: string }\n```\n\nThat'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.\n\n```ts\nconst status = 'idle';\n\u002F\u002F type: 'idle' — status can never change, so TypeScript keeps the literal\n\nconst config = { status: 'idle' };\n\u002F\u002F config.status type: string — config.status CAN be reassigned\n\u002F\u002F (config.status = 'loading' compiles fine), so TypeScript widens\n\u002F\u002F the property to the general type that covers anything you might assign\n```\n\nA `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:\n\n```ts\nconst methods = ['GET', 'POST'];\n\u002F\u002F type: string[] — any string could get pushed in later\n```\n\nThat 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.\n\nBefore you scroll — what do you think turns `string` back into the literal `'idle'`?\n\n## `as const` tells TypeScript to stop generalizing\n\n`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.\n\n```ts\nconst config = { status: 'idle' } as const;\n\u002F\u002F config.status type: 'idle'  (readonly)\n\nconst methods = ['GET', 'POST'] as const;\n\u002F\u002F type: readonly ['GET', 'POST']  — a fixed-length, literal tuple, not string[]\n```\n\nApply it to the action creators and the whole chain fixes itself:\n\n```ts\nfunction reset() {\n  return { type: 'reset' } as const;\n}\nfunction add(payload: Item) {\n  return { type: 'add', payload } as const;\n}\nfunction remove(id: string) {\n  return { type: 'remove', payload: id } as const;\n}\n\ntype CartAction = ReturnType\u003Ctypeof reset> | ReturnType\u003Ctypeof add> | ReturnType\u003Ctypeof remove>;\n\u002F\u002F { type: 'reset' } | { type: 'add'; payload: Item } | { type: 'remove'; payload: string }\n```\n\n`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:\n\n```ts\nfunction cartReducer(state: CartState, action: CartAction): CartState {\n  switch (action.type) {\n    case 'add':\n      return { ...state, items: [...state.items, action.payload] };\n    case 'remove':\n      return { ...state, items: state.items.filter((i) => i.id !== action.payload) };\n    default:\n      return assertNever(action); \u002F\u002F ← now a compile error if a case is missing\n  }\n}\n\nfunction assertNever(x: never): never {\n  throw new Error(`Unhandled action: ${JSON.stringify(x)}`);\n}\n```\n\nDelete 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.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ftypescript-as-const-assertions\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## The part that bites back\n\nHere'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.\n\n```ts\nconst METHODS = ['GET', 'POST'] as const;\n\nMETHODS.push('PUT');\n\u002F\u002F Error: Property 'push' does not exist on type 'readonly [\"GET\", \"POST\"]'\n```\n\nA `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:\n\n```ts\nfunction logAllowedMethods(methods: string[]) {\n  console.log(methods.join(', '));\n}\n\nlogAllowedMethods(METHODS);\n\u002F\u002F Error: Argument of type 'readonly [\"GET\", \"POST\"]' is not assignable\n\u002F\u002F to parameter of type 'string[]'. The type 'readonly [\"GET\", \"POST\"]'\n\u002F\u002F is 'readonly' and cannot be assigned to the mutable type 'string[]'.\n```\n\n`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:\n\n```ts\nfunction logAllowedMethods(methods: readonly string[]) { \u002F* ... *\u002F }\nlogAllowedMethods(METHODS); \u002F\u002F fine — the parameter now admits it won't mutate\n\n\u002F\u002F or, if the function genuinely can't change:\nlogAllowedMethods([...METHODS]); \u002F\u002F fine — a fresh, mutable copy\n```\n\nWiden 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.\n\n## Where it's worth reaching for\n\n- **Action\u002Fevent objects** — reducers, event buses, anywhere a `type` or `kind` field drives a switch. This is the highest-leverage use; it's the difference between a real exhaustiveness check and a decorative one.\n- **Fixed option lists** — `const THEMES = ['light', 'dark', 'system'] as const;` then `type 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 `\u003Cselect>`.\n- **Enum-free constant maps** — `const Role = { ADMIN: 'admin', EDITOR: 'editor' } as const;` reads better than a TypeScript `enum` for most teams and gives you `(typeof Role)[keyof typeof Role]` as the type.\n- **Not** for values you're going to mutate. If you need to `.push()` into it later, `as const` is actively the wrong tool — reach for an explicit type annotation instead.\n\nAnd 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.\n\nThe 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.\n\nWhere has a widened type let a real bug through for you — and did you reach for `as const`, or something else?\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 8-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ftypescript-as-const-assertions\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":110,"canonical":308,"description":309},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ftypescript-as-const-assertions","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 lit","01a157ee-b1c2-7318-af01-3b185764d4af",{"id":312,"locked":18},"01a157ee-b1fa-70ff-9945-1c84db204b99",[314],{"id":45,"slug":46,"title":48,"_count":315},{"questions":51},[317],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":319,"questionCount":51},{"questions":51},[]]