Skip to main content
4 min read

TypeScript is so satisfying

How `satisfies` lets you validate an object's shape without losing its inferred values.

typescriptreactfrontend

Shortly before TypeScript 4.9, we ran into an interesting problem. We were using feature flags and running a lot of multi-variant experiments, but we kept encountering preventable bugs: typos in variant names, typos in flag names. Our first thought was that TypeScript should be able to enforce these.

What we hoped to do was create a simple, strongly typed lookup table that mapped each flag name to its list of valid values. These types would then be enforced throughout the repo, making it impossible to reference a flag or value incorrectly (assuming it was correctly added to the lookup table in the first place). Something like this:

type Flag = {
  loggedIn: boolean; // loggedIn experiments differ from loggedOut experiments
  values: ['off', 'control', ...string[]]; // possible values; 'off' and 'control' are always required
};
 
const flags = {
  flagName: {
    loggedIn: false,
    values: ['off', 'control', 'test'],
  },
};

But how do we link the Flag type to the flags lookup?

The Problem: Two Bad Options

Prior to TypeScript 4.9, there were two ways to fix that, and both had a significant tradeoff.

Option 1: Annotate the type

const flags: Record<string, Flag> = {
  flagName: {
    loggedIn: false,
    values: ['off', 'control', 'test'],
  },
};

This gives you some validation: TypeScript will error if a flag doesn't match Flag. But we lose specificity on two fronts:

  1. Key specificity: the keys of flags are just string. You could manually define a union of valid keys, but then every key lives in two places.
  2. Value specificity: there's nothing linking a specific key to its specific values. As far as TypeScript is concerned, flags.flagName.values is ["off", "control", ...string[]], not the specific ["off", "control", "test"] we defined.

Option 2: Use as const

const flags = {
  flagName: {
    loggedIn: false,
    values: ['off', 'control', 'test'],
  },
} as const;

This is closer to what we want: as const preserves all the specific information from the lookup table. But there's nothing preventing us from adding malformed entries. Referencing the table should be easy, but so should adding to it, and right now there's nothing stopping a typo or a missing field from slipping in undetected.

You could have type safety, or you could have specificity. Not both.

The Fix: satisfies

This is exactly the problem the satisfies keyword was introduced to solve in TypeScript 4.9. It lets you validate that a value matches a type without widening the inferred type to match it.

const flags = {
  flagName: {
    loggedIn: false,
    values: ['off', 'control', 'test'],
  },
} as const satisfies Record<string, Flag>;

as const preserves the specific values. satisfies Record<string, Flag> validates the shape at the definition site. TypeScript errors immediately if a flag is missing a required field or has a value that doesn't match Flag, but the inferred type stays specific.

In practice, we ended up with a hook that looked something like this:

type FlagName = keyof typeof flags;
type FlagValue<K extends FlagName> = (typeof flags)[K]['values'][number]; // any element of the array
 
declare function useFlag<K extends FlagName>(flag: K): FlagValue<K>;

This hook only accepts a valid flag name and returns only a valid value for that specific flag, exactly what we wanted.

// TypeScript error: "invalidFlag" is not a key of flags
const val = useFlag('invalidFlag');
 
// TypeScript error: "variant1" is not assignable to "off" | "control" | "test"
if (useFlag('flagName') === 'variant1') {
}

Not only will these errors be caught, but your IDE will offer auto-complete for both flag names and values, so it's unlikely you'd accidentally type something wrong in the first place.

This also means that cleaning up a flag when an experiment has completed is safe! Once you delete the flag from the list, your code will error anywhere the flag is referenced!

Beyond Feature Flags

Feature flags are just one example of a broader pattern. Here are a few more examples:

Design tokens: a lookup table of colors, spacing, or typography values where you want each token to keep its specific literal type for use in styled components or CSS-in-JS:

type ColorValue = { hex: string; hsl: [number, number, number] };
 
const colors = {
  primary: { hex: '#3b82f6', hsl: [217, 91, 60] },
  secondary: { hex: '#8b5cf6', hsl: [263, 70, 50] },
} as const satisfies Record<string, ColorValue>;

Route config tables: a map of route names to their path and required params, so you can build a type-safe navigate() helper:

type Route = { path: string; params: string[] };
 
const routes = {
  home: { path: '/', params: [] },
  post: { path: '/blog/:slug', params: ['slug'] },
} as const satisfies Record<string, Route>;

The shape gets validated at definition time, without losing the specific inferred types downstream.

But why are you writing this NOW?

I know I'm about 3½ years late to the satisfies party, but I didn't have a blog back then! Besides, I still find this pattern extremely useful.

Worry not, though - now that I do have a blog, I should be able to get my reaction time down under 3 years.



Comments

Copyright © 2026 Robert Messerle.

All rights reserved.

...Or maybe some rights reserved? Who's to say?