TypeScript satisfies: Why My React Icon Map Rejected Every Prop
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 <Icon name="trash" /> instead of importing eleven SVGs. I typed the map on purpose, to catch mistakes. A string in there should fail the build.
It did fail. Everywhere. The map itself was fine. The trouble started at the call sites, where <ICONS.trash className="text-red-500" /> 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.
Before I found it, I did what I usually do: work around the compiler. const Icon = ICONS.trash as ComponentType<SVGProps<SVGSVGElement>> 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.
What the annotation did to the map
Here is the registry as I first wrote it. Two icon components, one map, one annotation.
import type { ComponentType, SVGProps } from 'react'
function TrashIcon(props: SVGProps<SVGSVGElement>) {
return <svg viewBox="0 0 24 24" {...props} />
}
function EditIcon(props: SVGProps<SVGSVGElement>) {
return <svg viewBox="0 0 24 24" {...props} />
}
// Typed on purpose, to keep non-components out of the map.
const ICONS: Record<string, ComponentType> = {
trash: TrashIcon,
edit: EditIcon,
}
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 Record<string, ComponentType>, and then the declared type is what the rest of the program sees. The inferred type of the literal, the one with typeof TrashIcon as a member, is thrown away. Every lookup returns bare ComponentType, a component with no props at all, so React rejects every attribute, including the ones it defines itself.
satisfies runs the same check and then steps back.
const ICONS = {
trash: TrashIcon,
edit: EditIcon,
} satisfies Record<string, ComponentType>
// ICONS.trash is typeof TrashIcon again
<ICONS.trash className="text-red-500" />
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 arrived in 4.9, and I gave it nearly four years before noticing, which tells you something about how I read release notes.
The same trap in route maps and form configs
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:
const ROUTES = {
home: '/',
dashboard: '/dashboard',
settings: '/settings',
} satisfies Record<string, string>
type RouteName = keyof typeof ROUTES
// 'home' | 'dashboard' | 'settings'
function navigate(to: RouteName) {
router.push(ROUTES[to])
}
With the annotation version, keyof typeof ROUTES is plain string, and navigate 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.
Form configs work the same way, and this is where as const joins in:
const FIELDS = [
{ name: 'email', label: 'Email', type: 'email' },
{ name: 'password', label: 'Password', type: 'password' },
] as const satisfies readonly { name: string; label: string; type: string }[]
type FieldName = (typeof FIELDS)[number]['name']
// 'email' | 'password'
The order matters: as const satisfies T. Swap the two and the compiler complains, which I know because I did it backwards first and assumed the feature was broken. as const 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.
Why I stopped reaching for as
For a long time my instinct was as. It reads like a cast because it is one, with one difference that matters: it checks nothing. { trash: TrashIcon, edti: EditIcon } as Record<string, ComponentType> compiles clean, typo and all, because you told the compiler what the type is and it believed you.
That's the right tool at a boundary where you know something the compiler can't, like a document.getElementById 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.
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.
Where satisfies is the wrong tool
satisfies is a compile-time operator, and it validates the expression as written, nothing else. This compiles without a word of complaint:
const config = JSON.parse(rawConfig) satisfies { retries: number }
If the server sends "retries": "three", the check passed anyway: it ran against any long before any JSON existed. Same story for process.env 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.
One more limit: Record<string, T> 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:
type Shape = { kind: 'circle'; radius: number } | { kind: 'square'; side: number }
const AREAS = {
circle: (s: Extract<Shape, { kind: 'circle' }>) => Math.PI * s.radius ** 2,
square: (s: Extract<Shape, { kind: 'square' }>) => s.side ** 2,
} satisfies { [K in Shape['kind']]: (shape: Extract<Shape, { kind: K }>) => number }
Leave out square and TypeScript names it in the error; add a triangle kind and it asks for that too. That's the exhaustiveness check people actually want when they reach for Record.
The habit I kept
Every example here is compiled. I ran them on the current 7.x compiler; the semantics are the ones 4.9 shipped, and the rewrite 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 Snippet Ark next to the route-map version, because the day I forget the order of as const satisfies is coming. It already did once.