DEV Community

Cover image for Your TypeScript config objects are losing type information. `satisfies` fixes it.
Parsa Jiravand
Parsa Jiravand

Posted on

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: 'Loading…',
  error: 'Something went wrong',
  success: 'Done',
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

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
Enter fullscreen mode Exit fullscreen mode
// 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'
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

type Path = '/' | '/about' | '/contact';

// Annotation: validates, then widens — the exact value and keys are gone
const routes: Record<string, Path> = { home: '/', about: '/about', contact: '/contact' };
routes.home;                   // Path  — not '/'
Object.keys(routes);           // string[]
type RouteKey = keyof typeof routes; // string  — any key is allowed

// satisfies: validates, then steps back — the exact value and keys survive
const routes = { home: '/', about: '/about', contact: '/contact' } satisfies Record<string, Path>;
routes.home;                   // '/'
type RouteKey = keyof typeof routes; // 'home' | 'about' | 'contact'
Enter fullscreen mode Exit fullscreen mode

Two things to notice. First, satisfies keeps the exact keys either way — keyof typeof routes is the real union, so key access and Object.keys stay precise. Second, the value literal '/' only survives because the target's value type (Path) is itself a literal union; against a wider target like Record<string, string> the values would still widen to string. Add a value outside Path to either declaration and 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:

Top comments (2)

Collapse
 
frank_signorini profile image
Frank

I've seen this issue in my own projects, how do you handle nested config objects with satisfies? I'd love to swap ideas on this.

Collapse
 
parsajiravand profile image
Parsa Jiravand

That's a great question! I generally use satisfies at the top level of the config object so TypeScript validates the overall shape while still preserving the most specific inferred types for nested values.

For more complex nested configs, I prefer breaking them into smaller objects and applying satisfies where each piece has its own contract. That keeps the types easier to read and produces more focused error messages when something doesn't match.

I also try to avoid excessive type assertions (as) alongside satisfies, since they can defeat the purpose of the extra type checking.

I'd be interested to hear how you're structuring your configs—are they mostly nested settings objects, or are you dealing with discriminated unions and more dynamic configurations?