Dev.to WebDev πŸ›  Dev πŸ‘ 1 πŸ“– 4 min read

Your TypeScript config objects are losing type information. `satisfies` fixes it.

There is a config.ts in most TypeScript projects that looks something like this: type Status = 'idle' | 'loading' | 'error' | 'success'; const STATUS_LABELS: Record<Status, string> = { idle: 'Waiting', loading: '

There is a config.ts in most TypeScript projects that looks something like this:

type Status = 'idle' | 'loading' | 'error' | 'success';

const STATUS_LABELS: Record<Status, string> = {
  idle: 'Waiting',
  loading: 'Loading…',
  error: 'Something went wrong',
  success: 'Done',
};

The annotation validates the object. Miss a key, TypeScript tells you. Use a non-string value, TypeScript tells you. That's the whole point of the annotation. Now look at what you get when you use it:

STATUS_LABELS.idle; // type: string

TypeScript knows the value is a string. It doesn't know it's 'Waiting'. The moment you wrote : Record<Status, string>, TypeScript swapped the inferred type for the declared type and threw away the actual values. You validated the shape, but you gave up precision to do it.

For STATUS_LABELS, that trade is usually fine. But pick a more demanding example β€” a component icon registry:

const ICONS: Record<string, React.ComponentType<any>> = {
  trash: TrashIcon,
  edit:  EditIcon,
  copy:  CopyIcon,
};

ICONS.trash; // type: React.ComponentType<any>

You've lost the specific component types. TrashIcon might have a size prop; TypeScript no longer knows. Any access through ICONS returns the widened union. The annotation bought validation and cost you everything the concrete types were worth.

What satisfies does differently

satisfies arrived in TypeScript 4.9 (November 2022). The syntax is expression satisfies Type. It checks that the left side is compatible with the type on the right β€” the same check as an annotation β€” but the resulting inferred type stays as the expression's own type, not the annotation's.

const ICONS = {
  trash: TrashIcon,
  edit:  EditIcon,
  copy:  CopyIcon,
} satisfies Record<string, React.ComponentType<any>>;

ICONS.trash; // type: typeof TrashIcon β€” the concrete type, preserved

TypeScript validates that each value is a React.ComponentType. If you put a string in there by mistake, you get an error. But the inferred type of ICONS.trash stays as typeof TrashIcon. The check happened; the widening didn't.

Where it matters most

Route maps. A central route object is a common pattern, and getting its keys as a typed union is useful for navigation functions:

// With annotation β€” keys become string
const ROUTES: Record<string, string> = {
  home:     '/',
  about:    '/about',
  settings: '/settings',
};

type RouteName = keyof typeof ROUTES; // string β€” not what you wanted
// With satisfies β€” keys stay literal
const ROUTES = {
  home:     '/',
  about:    '/about',
  settings: '/settings',
} satisfies Record<string, string>;

type RouteName = keyof typeof ROUTES; // 'home' | 'about' | 'settings'

function navigate(to: RouteName) { /* ... */ }

navigate('home');    // fine
navigate('contact'); // Error: Argument of type '"contact"' is not assignable to parameter of type 'RouteName'

The annotation validated the paths are strings. satisfies did the same validation and also kept the named keys as a union β€” so every call to navigate() is checked against the actual routes in your config.

Exhaustive pattern validation. When you have a discriminated union and want to map every case to something:

type Shape = { kind: 'circle'; radius: number } | { kind: 'square'; side: number };

const AREA_FORMULAS = {
  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 };

Add a new shape kind to the union and TypeScript immediately tells you AREA_FORMULAS is no longer exhaustive β€” without you having to look it up.

How it compares to as

as is a type assertion: you are telling TypeScript what the type is, skipping its inference. It's useful when TypeScript can't see something you can β€” reading from the DOM, narrowing after an external type guard β€” but it provides no validation.

// as β€” you're asserting, not checking
const el = document.getElementById('app') as HTMLDivElement;
// TypeScript trusts you; it doesn't verify the element is a div

satisfies is the opposite: TypeScript checks, then steps back. Use as when the compiler lacks information and you have it. Use satisfies when you have information and want the compiler to verify it without losing it. Use : Type annotation when you want to commit to the wider type throughout the file and don't need the narrow inference.

Spot the difference

// Annotation: validates, widens
const palette: Record<string, string> = { red: '#f00', blue: '#00f' };
palette.red;                   // string
Object.keys(palette);         // string[]
type PaletteKey = keyof typeof palette; // string

// satisfies: validates, narrows
const palette = { red: '#f00', blue: '#00f' } satisfies Record<string, string>;
palette.red;                   // '#f00'
type PaletteKey = keyof typeof palette; // 'red' | 'blue'

Add a non-string value to either β€” TypeScript catches it. The only difference is what comes out the other side.

The rule

Use satisfies for any const you define inline and then access by its specific keys or values β€” config maps, icon registries, route definitions, theme tokens, anything where the precise shape matters downstream. Use annotation when you want to commit to the wider type and everything accessing it should see only that wider type. Use as when TypeScript can't see the correct type on its own.

You've been choosing between safety and precision. satisfies was added so you don't have to.

Thanks for reading! Let's stay connected:

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.