satisfies, NoInfer and the design system that stopped lying to us
TypeScript patterns we use for component variants, with the type errors that finally convinced my team
Contents
Our design system has around 60 components, 4 brands, and one recurring bug class: a value that TypeScript said was fine and the UI said was not. Here are the three patterns that killed most of them. Nothing exotic, yani, but my team only believed me after seeing the errors.
1. Tokens: satisfies instead of an annotation
We had this:
const toneColors: Record<string, string> = {
neutral: '#3f3f46',
success: '#15803d',
danger: 'red', // someone, at 18:55 on a Friday
}
toneColors.sucess // no error, just undefined at runtime
The annotation widens everything. Keys become string, so typos compile. Values become string, so 'red' compiles and dark mode breaks. With satisfies the object is checked against the shape but keeps its own literal type:
type Tone = 'neutral' | 'success' | 'danger'
type Hex = `#${string}`
const toneColors = {
neutral: '#3f3f46',
success: '#15803d',
danger: '#b91c1c',
} satisfies Record<Tone, Hex>
type ToneKey = keyof typeof toneColors // 'neutral' | 'success' | 'danger'
toneColors.sucess // error: Property 'sucess' does not exist
Forget a tone and you get "Property 'danger' is missing". Add a tone that does not exist and you get an excess property error. Put 'red' and the template literal type complains. All at build time.
2. Generic components: let the options decide, not the value
Our Select looked type safe:
type Option<V extends string> = { value: V; label: string }
function Select<V extends string>(props: {
options: readonly Option<V>[]
value: V
onChange: (value: V) => void
}) {
/* ... */
}
const sizes = [
{ value: 'sm', label: 'Small' },
{ value: 'md', label: 'Medium' },
] as const satisfies readonly Option<string>[]
<Select options={sizes} value="lg" onChange={setSize} /> // compiles. why??
It compiles because TypeScript infers V from every place it appears. The options say 'sm' | 'md', the value says 'lg', so V becomes 'sm' | 'md' | 'lg' and everyone is happy except the user. I lost one afternoon and two çay to this.
The fix is NoInfer (TypeScript 5.4+). It tells the compiler: do not use this position to infer V, only to check it.
function Select<V extends string>(props: {
options: readonly Option<V>[]
value: NoInfer<V>
onChange: (value: NoInfer<V>) => void
}) {
/* ... */
}
<Select options={sizes} value="lg" onChange={setSize} />
// error: Type '"lg"' is not assignable to type '"sm" | "md"'
3. Props that cannot exist together
A button that is a link needs href. A button that is a button needs onClick. Not both, not neither. A discriminated union says that exactly:
type ButtonProps =
| { variant: 'link'; href: string; onClick?: never }
| { variant: 'solid' | 'ghost'; onClick: () => void; href?: never }
function Button(props: ButtonProps & { children: React.ReactNode }) {
if (props.variant === 'link') {
return <a href={props.href}>{props.children}</a>
}
return <button onClick={props.onClick}>{props.children}</button>
}
Inside each branch TypeScript knows which props exist, so no props.href! with the exclamation mark of shame.
The one line version for your juniors
- An annotation widens.
aslies.satisfieschecks and keeps.
We also turned on the lint rule that bans as outside tests. Two weeks of complaining, then silence, then one of the loudest complainers said in a retro that he had not had a "how did this compile" bug since. Kolay gelsin to everyone migrating their tokens 🙃
Written by
Elif Korkmaz
Frontend lead @ e-commerce, İstanbul. TypeScript, design systems, çay. Bazen Türkçe yazarım, kusura bakmayın 🙃
Get the best of Dynamo Geeks weekly
One email each Monday with the most useful posts. No spam; unsubscribe in one click.
Comments
Sign in to join the conversation.
Sign in to comment