Skip to content
article

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

Elif Korkmaz@elif_ships

July 25, 2026 · 3 min read

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.
  • as lies.
  • satisfies checks 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

9

Sign in to join the conversation.

Sign in to comment
Sofía Ramírez@sofi_dev_mx
The NoInfer part!! I have 6 years working with React and I did not know this. We had exactly the bug with the value widening, a Select accepting any string. Gracias Elif 💜
3 net votes
Elif Korkmaz@elif_ships
rica ederim 🙃 it took me a full afternoon and two çay to understand why TS was 'helping' me
3 net votes
Ondřej Hrubý@rewrite_it_in_rust
in Rust this is just an enum and match, no need for 4 tricks to make the compiler stop lying 🦀
5 net votes
Elif Korkmaz@elif_ships
hocam these props come from JSON that a CMS editor fills in. show me your enum surviving the CMS editor 🙃
4 net votes
Giulia Bellandi@giulia_designs
As the designer who makes those tokens: THANK YOU ✨ the hex check would have saved us the famous 'danger: red' commit that broke dark mode ahah
3 net votes
Karthik Subramaniam@karthi_rustacean
Nicely explained. satisfies is the most useful operator added to TypeScript in years only. 'annotation widens, as lies, satisfies checks' is a good line to teach juniors.
3 net votes
Danny Cho@danny_cho_la
Forwarding to our frontend folks. 'as lies' should be a lint rule.
2 net votes
Elif Korkmaz@elif_ships
it is! typescript-eslint has consistent-type-assertions, we set assertionStyle to never and only allow it in test files
1 net votes
Sneha Kulkarni@laidoff_not_out
saving this for interviews. 'what does satisfies do' came up in two of my last six frontend rounds. most people answer with as const and get half marks.
3 net votes