You declare a config object, annotate it : Record<string, string> to catch typos, and then find that indexing a key you can see in the source returns string | undefined. The annotation that validated your object also erased everything specific about it. TypeScript 4.9 added satisfies for this case. It checks a value against a constraint without letting the constraint overwrite what the compiler already inferred.

Three tools look interchangeable here. They are not. They differ in what they do to the inferred type, and that difference is the whole point.

Three tools, three failure modes

Take a single value and apply each in turn.

type Color = `#${string}`;

const a: Record<string, Color> = {
  brand: '#0a7d2c',
  danger: '#c1121f',
};
// type of a: Record<string, Color>
// a.brand is Color, but a.missing is ALSO Color, no error on unknown keys

const b = {
  brand: '#0a7d2c',
  danger: 'not-a-color',
} as Record<string, Color>;
// no error, despite 'not-a-color'. `as` asserts, it does not check

const c = {
  brand: '#0a7d2c',
  danger: 'not-a-color',
} satisfies Record<string, Color>;
// Error: 'not-a-color' is not assignable to Color

The annotation on a validates the literal, then replaces its type with Record<string, Color>. You lose the fact that the object has exactly two keys. The assertion on b keeps the shape but disables checking. 'not-a-color' sails through, and you find out at runtime or never. Only satisfies does both jobs. It verifies the value against Record<string, Color> and leaves the inferred type alone.

satisfies is an expression-position operator, not a declaration annotation. It produces the original value with its original inferred type, having checked assignability as a side effect.

Keys you can index, autocomplete you can trust

The common case is a palette or config map. You want the constraint Record<string, string> to guarantee every value is a string. You do not want to forget which keys exist.

const palette = {
  bg: '#0b0f14',
  fg: '#e6edf3',
  accent: '#3fb950',
} satisfies Record<string, string>;

palette.accent;
//      ^? string, a known key, so a plain string, never undefined

palette.border;
// Error: Property 'border' does not exist on type
//        { bg: string; fg: string; accent: string }

The inferred type is { bg: string; fg: string; accent: string }. So palette.accent is string and palette.border is a compile error. Autocomplete offers bg, fg, and accent, nothing else.

Now annotate instead and watch the key information vanish:

const palette: Record<string, string> = {
  bg: '#0b0f14',
  fg: '#e6edf3',
  accent: '#3fb950',
};

palette.accent;
//      ^? string, but only because every string key is allowed

palette.border;
//      ^? string, no error, the index signature invents it

Both palette.accent and palette.border are string now, including the key that does not exist. The index signature made every property access succeed. That is the opposite of what you wanted from typing the object.

It still catches the errors annotations catch

satisfies is not a softer check. Excess properties, missing properties, and wrong value types all still fail, because assignability runs in full.

type Route = { path: string; auth: boolean };

const routes = {
  home: { path: '/', auth: false },
  admin: { path: '/admin', auth: 'yes' },
  // Error on 'yes': string is not assignable to boolean
} satisfies Record<string, Route>;

const config = {
  retries: 3,
  timeuot: 5000, // Error: 'timeuot' does not exist in type { retries: number; timeout: number }
} satisfies { retries: number; timeout: number };

This line separates it from as. An assertion accepts both of these without complaint, because as tells the compiler to stop reasoning. satisfies keeps reasoning. It reports 'yes' and the misspelled timeuot while still inferring the narrow object type for routes and config.

as const satisfies: validate the shape, freeze the literals

satisfies controls widening. It does not stop the literal-to-base widening that a mutable binding triggers on its own. A boolean stays boolean, a number stays number. When you want the narrowest possible types (literal unions, readonly tuples, exact strings), pair it with as const. The as const freezes the literals. The satisfies checks the frozen value against your contract.

const theme = {
  mode: 'dark',
  spacing: [4, 8, 16],
  radius: 8,
} as const satisfies {
  mode: 'light' | 'dark';
  spacing: readonly number[];
  radius: number;
};

type Mode = typeof theme.mode;
//   ^? 'dark', not string, and constrained to the union at the type site

type Spacing = typeof theme.spacing;
//   ^? readonly [4, 8, 16], a fixed tuple, not number[]

The ordering matters. as const runs first to lock the literals, then satisfies verifies that 'dark' is a valid mode and that the tuple matches readonly number[]. Get the contract wrong, set mode: 'drak', and you get an error against the union rather than a silent widening. The same pattern keys a routes table to exact string paths:

const routes = {
  home: '/',
  profile: '/users/:id',
} as const satisfies Record<string, `/${string}`>;

type Paths = (typeof routes)[keyof typeof routes];
//   ^? '/' | '/users/:id'

You now have a literal union of paths to feed into a template-literal router or a parser built on template literal types. The constraint /${string} rejects any entry that forgets its leading slash.

When the annotation is still correct

satisfies is for the value site, where you own the literal and want to keep its shape. It is the wrong reach for a public function signature, where widening is the feature.

// Right: the contract is the API. Callers should not see the implementation's
// narrow return type; they should see what you promise.
function loadConfig(): Record<string, string> {
  return { region: 'eu-west-1' } satisfies Record<string, string>;
}

Annotate the boundary, use satisfies inside it. The annotation on loadConfig is deliberate. Every caller couples to Record<string, string>, so you can change region without breaking them. Reach for an explicit annotation whenever the declared type is the interface and you want the broader type to propagate: parameters, exported return types, and any binding whose precise shape is an implementation detail you would rather hide.

Everywhere else, when the object in front of you is the source of truth and you want to keep what the compiler knows about it, satisfies checks your work without rewriting it. From here, branded and nominal types show how to make those checks sharper. They constrain values the structural system would otherwise wave through.