DevgainsDevgainsDevgains
All articles

satisfies: The Operator You Should Be Using

·5 min read
satisfies: The Operator You Should Be Using

Photo: Unsplash

For years TypeScript gave you two bad options when you wanted to type a literal value. Annotate it with : SomeType and the compiler widens your value, throwing away the precise literal information you wanted to keep. Leave it unannotated and you get the precise type, but nothing checks that the value actually conforms to the contract you had in mind. You were forced to choose between validation and precision.

The satisfies operator, added in TypeScript 4.9, ends that tradeoff. It checks that an expression matches a type without changing the type the compiler infers for it. You get the error messages of an annotation and the narrow, literal type of an unannotated value at the same time.

The widening problem in one example

Consider a palette object where each color is an RGB tuple or a hex string. You want to guarantee every value is one of those shapes, but you also want to read individual entries with their exact types.

type Color = [number, number, number] | string;
 
// Annotation: validated, but widened.
const paletteA: Record<string, Color> = {
  red: [255, 0, 0],
  border: "#ccc",
};
paletteA.red.map((c) => c);   // Error: 'map' may not exist on 'string'
const k1 = paletteA.notReal;  // No error: key is just 'string'

The annotation widened paletteA.red to the full Color union, so the compiler no longer knows it is a tuple. And because the index signature is Record<string, Color>, typos in keys are invisible. This is precisely the situation satisfies was built for.

What satisfies changes

Swap the annotation for satisfies and the value keeps its inferred type while still being checked against Record<string, Color>.

const paletteB = {
  red: [255, 0, 0],
  border: "#ccc",
} satisfies Record<string, Color>;
 
paletteB.red.map((c) => c * 2); // OK: red is inferred as number[]
paletteB.border.toUpperCase();  // OK: border is inferred as string
const k2 = paletteB.notReal;    // Error: 'notReal' does not exist

Notice the three wins at once. Each property keeps its specific type, so red is an array and border is a string. The keys are exact, so a typo is a compile error. And the whole object is still validated against the contract, so adding a property whose value is a boolean would fail immediately. The official write-up lives in the TypeScript handbook, and it is worth bookmarking the release notes for the exact semantics.

Mental model: satisfies is a checkpoint, not a cast. It asks "does this value fit the type?" and then steps out of the way, leaving the precise inferred type untouched. as does the opposite, forcing a type without inference.

satisfies versus as versus annotation

These three look similar but mean very different things, and mixing them up causes real bugs.

const a = { x: 1 } as { x: number; y: number }; // Lie: y is missing, no error
const b: { x: number } = { x: 1 };               // Validated, widened to {x: number}
const c = { x: 1 } satisfies { x: number };      // Validated, inferred as {x: number}

The as assertion is the dangerous one. It tells the compiler to trust you even when you are wrong, which is why the missing y produces no error. An annotation validates but discards precision. The satisfies operator validates and preserves precision. Reach for as only when you genuinely know more than the compiler, such as narrowing a DOM lookup; reach for satisfies whenever you are defining a literal value against a known shape.

Practical places it shines

Configuration objects are the headline use case, but the pattern shows up everywhere you have a fixed map of values.

type Route = { path: string; auth: boolean };
 
const routes = {
  home: { path: "/", auth: false },
  dashboard: { path: "/app", auth: true },
} satisfies Record<string, Route>;
 
// keyof works on the precise keys, not just `string`.
type RouteName = keyof typeof routes; // "home" | "dashboard"
 
function go(name: RouteName) { /* ... */ }
go("dashbord"); // Error caught at compile time

Because the inferred type is preserved, keyof typeof routes gives you the real union of route names. You could not get that from a Record annotation; it would collapse to string. The same trick works for theme tokens, feature flags, lookup tables, and discriminated event maps. Anywhere you would have written as const plus a manual check, satisfies often does both jobs and validates the shape too.

Combining satisfies and as const

The two features compose. Use as const to freeze values into their narrowest literal types, then satisfies to assert the frozen value matches your contract.

const breakpoints = {
  sm: 640,
  md: 768,
  lg: 1024,
} as const satisfies Record<string, number>;
 
// Values are the literal numbers 640 | 768 | 1024, and shape is verified.
type Width = (typeof breakpoints)[keyof typeof breakpoints];

Order matters: as const first to lock the literals, then satisfies to validate. You now have readonly literal values that are also guaranteed to be numbers. If you want a deeper look at the freezing half of this pattern, the MDN reference on the const declaration is a good primer on the runtime side, while the type-level behavior is covered in the TypeScript docs.

When not to reach for it

satisfies is for places where you want to keep the inferred type. If a function parameter or a public API field should be exactly the declared type, an annotation is clearer and intentionally hides implementation detail. There is also no point using satisfies on a value you immediately pass somewhere typed, since the contract is already enforced there. Use it where the extra precision earns its keep: literals you read back, keys you map over, and config you want fully checked.

Takeaways

  • satisfies validates a value against a type without widening its inferred type.
  • Annotations validate but throw away literal precision; as skips validation entirely.
  • It keeps exact property types and exact keys, so keyof typeof returns real unions.
  • Compose as const satisfies T to get readonly literal values that are also shape-checked.
  • Prefer it for config objects, lookup tables, and token maps; prefer annotations for public APIs.
  • It is a compile-time checkpoint, so there is zero runtime cost.
5 min read

Read next