[{"data":1,"prerenderedAt":6},["ShallowReactive",2],{"post-content-typescript-satisfies-react-icon-map":3},{"content":4,"lastModified":5},"\u003Cp>Last month I added an icon registry to a React app: a map from a name to a component, so a settings row could render \u003Ccode>&lt;Icon name=\"trash\" \u002F&gt;\u003C\u002Fcode> instead of importing eleven SVGs. I typed the map on purpose, to catch mistakes. A string in there should fail the build.\u003C\u002Fp>\n\n\u003Cp>It did fail. Everywhere. The map itself was fine. The trouble started at the call sites, where \u003Ccode>&lt;ICONS.trash className=\"text-red-500\" \u002F&gt;\u003C\u002Fcode> came back with \"Property 'className' does not exist on type 'IntrinsicAttributes'\". The fix was an operator called satisfies, in the language since TypeScript 4.9, and the annotation I reached for first was doing the damage.\u003C\u002Fp>\n\n\u003Cp>Before I found it, I did what I usually do: work around the compiler. \u003Ccode>const Icon = ICONS.trash as ComponentType&lt;SVGProps&lt;SVGSVGElement&gt;&gt;\u003C\u002Fcode> at one call site, then at the next, then in a shared wrapper so the cast lived in one place. The wrapper is still in that codebase, solving a problem that was never the map's fault.\u003C\u002Fp>\n\n\u003Cimg src=\"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1517180102446-f3ece451e9d8?auto=compress&amp;cs=tinysrgb&amp;w=1200\" alt=\"Source code open in the browser's element inspector, with a computed styles panel beside it\" loading=\"lazy\" \u002F>\n\n\u003Ch2>What the annotation did to the map\u003C\u002Fh2>\n\n\u003Cp>Here is the registry as I first wrote it. Two icon components, one map, one annotation.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-tsx\">import type { ComponentType, SVGProps } from 'react'\n\nfunction TrashIcon(props: SVGProps&lt;SVGSVGElement&gt;) {\n  return &lt;svg viewBox=\"0 0 24 24\" {...props} \u002F&gt;\n}\n\nfunction EditIcon(props: SVGProps&lt;SVGSVGElement&gt;) {\n  return &lt;svg viewBox=\"0 0 24 24\" {...props} \u002F&gt;\n}\n\n\u002F\u002F Typed on purpose, to keep non-components out of the map.\nconst ICONS: Record&lt;string, ComponentType&gt; = {\n  trash: TrashIcon,\n  edit: EditIcon,\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Put a string in there and the build fails, as intended. What I didn't expect is what the annotation does downstream. A type annotation is not a comment: TypeScript checks the object against \u003Ccode>Record&lt;string, ComponentType&gt;\u003C\u002Fcode>, and then the declared type is what the rest of the program sees. The inferred type of the literal, the one with \u003Ccode>typeof TrashIcon\u003C\u002Fcode> as a member, is thrown away. Every lookup returns bare \u003Ccode>ComponentType\u003C\u002Fcode>, a component with no props at all, so React rejects every attribute, including the ones it defines itself.\u003C\u002Fp>\n\n\u003Cp>satisfies runs the same check and then steps back.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-tsx\">const ICONS = {\n  trash: TrashIcon,\n  edit: EditIcon,\n} satisfies Record&lt;string, ComponentType&gt;\n\n\u002F\u002F ICONS.trash is typeof TrashIcon again\n&lt;ICONS.trash className=\"text-red-500\" \u002F&gt;\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>The check is identical, so a string in the map still fails the build. The difference is that the check happens and the type survives it. The compiler checks your work and leaves the room instead of moving in. It \u003Ca href=\"https:\u002F\u002Fwww.typescriptlang.org\u002Fdocs\u002Fhandbook\u002Frelease-notes\u002Ftypescript-4-9.html\" target=\"_blank\" rel=\"noopener noreferrer\">arrived in 4.9\u003C\u002Fa>, and I gave it nearly four years before noticing, which tells you something about how I read release notes.\u003C\u002Fp>\n\n\u003Ch2>The same trap in route maps and form configs\u003C\u002Fh2>\n\n\u003Cp>The icon registry was the loud version of a quieter problem. Any object that is both a value and a contract has it. A route map is the classic:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-tsx\">const ROUTES = {\n  home: '\u002F',\n  dashboard: '\u002Fdashboard',\n  settings: '\u002Fsettings',\n} satisfies Record&lt;string, string&gt;\n\ntype RouteName = keyof typeof ROUTES\n\u002F\u002F 'home' | 'dashboard' | 'settings'\n\nfunction navigate(to: RouteName) {\n  router.push(ROUTES[to])\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>With the annotation version, \u003Ccode>keyof typeof ROUTES\u003C\u002Fcode> is plain \u003Ccode>string\u003C\u002Fcode>, and \u003Ccode>navigate\u003C\u002Fcode> accepts anything. With satisfies, the keys stay literal, so a typo at the call site is a compile error, and your editor lists the real routes instead of shrugging. I got a \"Did you mean 'dashboard'?\" the other day for a route name I'd misspelled. The compiler was more useful than my own memory.\u003C\u002Fp>\n\n\u003Cp>Form configs work the same way, and this is where \u003Ccode>as const\u003C\u002Fcode> joins in:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-tsx\">const FIELDS = [\n  { name: 'email', label: 'Email', type: 'email' },\n  { name: 'password', label: 'Password', type: 'password' },\n] as const satisfies readonly { name: string; label: string; type: string }[]\n\ntype FieldName = (typeof FIELDS)[number]['name']\n\u002F\u002F 'email' | 'password'\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>The order matters: \u003Ccode>as const satisfies T\u003C\u002Fcode>. Swap the two and the compiler complains, which I know because I did it backwards first and assumed the feature was broken. \u003Ccode>as const\u003C\u002Fcode> freezes the literals; satisfies checks each entry against the shape without flattening it. The field names come out as a reusable union, and adding a field updates it for free.\u003C\u002Fp>\n\n\u003Ch2>Why I stopped reaching for as\u003C\u002Fh2>\n\n\u003Cp>For a long time my instinct was \u003Ccode>as\u003C\u002Fcode>. It reads like a cast because it is one, with one difference that matters: it checks nothing. \u003Ccode>{ trash: TrashIcon, edti: EditIcon } as Record&lt;string, ComponentType&gt;\u003C\u002Fcode> compiles clean, typo and all, because you told the compiler what the type is and it believed you.\u003C\u002Fp>\n\n\u003Cp>That's the right tool at a boundary where you know something the compiler can't, like a \u003Ccode>document.getElementById\u003C\u002Fcode> result. It's the wrong tool for a literal in your own file, where the compiler would have caught the typo if you'd let it.\u003C\u002Fp>\n\n\u003Cp>So the three options line up like this. The annotation checks the value and replaces the type. The assertion replaces the type and skips the check. satisfies checks the value and keeps the type. When you're looking at a literal in your own source, that third combination is almost always what you wanted. It took me an embarrassing amount of time to notice.\u003C\u002Fp>\n\n\u003Ch2>Where satisfies is the wrong tool\u003C\u002Fh2>\n\n\u003Cp>satisfies is a compile-time operator, and it validates the expression as written, nothing else. This compiles without a word of complaint:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-tsx\">const config = JSON.parse(rawConfig) satisfies { retries: number }\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>If the server sends \u003Ccode>\"retries\": \"three\"\u003C\u002Fcode>, the check passed anyway: it ran against \u003Ccode>any\u003C\u002Fcode> long before any JSON existed. Same story for \u003Ccode>process.env\u003C\u002Fcode> and anything that crosses a network. That data needs a runtime check, a schema library or a hand-written guard. satisfies can sit on top of one, but it is not one.\u003C\u002Fp>\n\n\u003Cp>One more limit: \u003Ccode>Record&lt;string, T&gt;\u003C\u002Fcode> accepts any string key, so a misspelled key is not an error. When the keys are a fixed set, constrain them with a mapped type, and a missing entry becomes a build failure:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-tsx\">type Shape = { kind: 'circle'; radius: number } | { kind: 'square'; side: number }\n\nconst AREAS = {\n  circle: (s: Extract&lt;Shape, { kind: 'circle' }&gt;) =&gt; Math.PI * s.radius ** 2,\n  square: (s: Extract&lt;Shape, { kind: 'square' }&gt;) =&gt; s.side ** 2,\n} satisfies { [K in Shape['kind']]: (shape: Extract&lt;Shape, { kind: K }&gt;) =&gt; number }\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Leave out \u003Ccode>square\u003C\u002Fcode> and TypeScript names it in the error; add a \u003Ccode>triangle\u003C\u002Fcode> kind and it asks for that too. That's the exhaustiveness check people actually want when they reach for \u003Ccode>Record\u003C\u002Fcode>.\u003C\u002Fp>\n\n\u003Ch2>The habit I kept\u003C\u002Fh2>\n\n\u003Cp>Every example here is compiled. I ran them on the current 7.x compiler; the semantics are the ones 4.9 shipped, and the \u003Ca href=\"\u002Fposts\u002Ftypescript-7-native-compiler-what-changes\u002F\">rewrite\u003C\u002Fa> bought speed, not opinions about widening. My rule now is small enough to follow: no annotation on a literal unless I want the wider type, and satisfies on anything that is both a value and a contract. The registry pattern lives in \u003Ca href=\"\u002Fsnippetark\u002F\">Snippet Ark\u003C\u002Fa> next to the route-map version, because the day I forget the order of \u003Ccode>as const satisfies\u003C\u002Fcode> is coming. It already did once.\u003C\u002Fp>\n","2026-10-18",1792462273090]