open atlas
↑ Back to track
TypeScript type system, deep TS · 06 · 04

The satisfies operator

How `satisfies` validates a value against a type without widening it — keeping the precise inferred type that an annotation would discard — contrasted with annotation and `as`, plus where you still need a contextual type.

TS Senior ◷ 14 min
Level
FoundationsJuniorMiddleSenior

You have a route table: { home: "/", profile: "/u/:id" }. You want two things at once: the compiler should check that every value is a valid path string, and it should remember the exact literal keys so routes.home is typed "/", not string. An annotation gives you the check but throws away the literals. A cast keeps the literals but does no real check. For years you had to choose. satisfies is the operator that gives you both.

The three tools on one value

When you find yourself choosing between type safety and type precision, pause — you may not have to choose. The question to ask is: do I need the target type to flow in and drive inference, or do I need a check from outside that leaves the inferred type intact?

Put the same object through each tool and watch what happens to its type.

type Routes = Record<string, `/${string}`>;

// 1) Annotation — validates, but WIDENS to the annotated type
const a: Routes = { home: "/", profile: "/u/:id" };
a.home;
// ^? `/${string}`   — lost the literal "/"; a.home is just "a path string"
// a.dashboard;      // no error — keys are Record<string, ...>, completeness not enforced

// 2) as — keeps a type you assert, but does NO real check
const b = { home: "/", dashboard: "oops" } as Routes;
//                     ^^^^^^^^ typo + invalid path, but `as` silences it
b.home;
// ^? `/${string}`   — also widened, and the bug slipped through

// 3) satisfies — validates AND keeps the precise inferred type
const c = { home: "/", profile: "/u/:id" } satisfies Routes;
c.home;
// ^? "/"            — exact literal preserved
c.profile;
// ^? "/u/:id"
// const d = { home: "x" } satisfies Routes;
// Error: Type 'string' is not assignable to type `/${string}` — invalid path caught

The annotation widens: the variable’s type becomes Routes, so a.home forgets it was "/". The cast lies: as performs an assignability check in one direction only and suppresses excess-property and value errors — dashboard: "oops" sails through. satisfies validates without widening: the value is checked against Routes, but the variable keeps its inferred type { home: "/"; profile: "/u/:id" }, so each literal survives.

Both completeness checking and precise per-key types

The canonical win is a record where you want the key set checked and the per-key value types kept precise. Use a constraint type to enforce shape, and satisfies to keep the inference:

type Color = "red" | "green" | "blue";

const palette = {
  red: [255, 0, 0],
  green: [0, 255, 0],
  blue: [0, 0, 255],
} satisfies Record<Color, [number, number, number]>;

// Completeness: drop a key and you get an error
// const p2 = { red: [255,0,0], green: [0,255,0] } satisfies Record<Color, [number,number,number]>;
// Error: Property 'blue' is missing.

palette.red;
// ^? [number, number, number]   — kept as a tuple, not number[]
palette.red[0].toFixed(2);
// ok — element is `number`, available because the tuple type survived
const keys = Object.keys(palette);
// the value still has exact keys, so downstream code can rely on them

With a plain annotation const palette: Record<Color, ...>, palette.red would be widened and you’d lose the tuple-vs-array precision and the exact key set. satisfies keeps both: the constraint validates completeness and value shape; the inferred type keeps the literals and tuples.

Why this works

Why does satisfies not change the type at all? Because it is a type-system-only assertion of conformance, not a coercion. The expression’s type is computed by normal inference (which is precise for as const-free literals up to the usual widening rules), and satisfies T simply adds the constraint “this inferred type must be assignable to T” as a compile-time check. If it passes, the inferred type flows out unchanged. It is the inverse of an annotation, which imposes T as the type rather than checking against it.

The trap: satisfies does not provide a contextual type

The one place satisfies cannot replace an annotation is contextual typing. An annotation on a variable flows its type inward to give un-annotated function parameters their types. satisfies only checks outward after the value is already inferred, so it does not seed parameters.

type Handlers = { onClick: (e: MouseEvent) => void };

// Annotation: the type flows in, so `e` is contextually typed
const h1: Handlers = {
  onClick: (e) => { e.clientX; },   // e: MouseEvent — inferred from the annotation
};

// satisfies: checked AFTER inference, so `e` gets no contextual type
const h2 = {
  onClick: (e) => { e.clientX; },
  //         ^ Error: Parameter 'e' implicitly has an 'any' type.
} satisfies Handlers;

In h2, the arrow’s parameter e is inferred before satisfies runs its check, so there is nothing to contextually type it — under noImplicitAny it’s an error; you must annotate e: MouseEvent yourself. The rule: reach for an annotation when you need the target type to drive inference of callback parameters; reach for satisfies when you want to validate a value while preserving its precise inferred type. They are complementary, not interchangeable.

Quiz

For `const cfg = { port: 8080 } satisfies Record<string, number>`, what is the type of `cfg.port`, and why?

Complete the analogy

Think of the three tools as ways to handle a parcel at customs. An annotation repacks everything into a standard box (you pass inspection but lose the original packaging). `as` waves it through with a forged stamp (no real inspection). `satisfies` is the inspector who verifies the contents against the rules and then ___ — which is exactly why your literal types survive.

Recall before you leave
  1. 01
    Contrast annotation, `as`, and `satisfies` on the same object literal: what does each do to the type and to safety?
  2. 02
    When can `satisfies` NOT replace an annotation, and why?
Recap

satisfies (TS 4.9) checks a value against a type without widening it, preserving the precise inferred type that an annotation would discard and the real validation that as skips. The three tools form a clear table: annotation checks but widens (literals lost, completeness on records not enforced), as keeps the asserted type but performs no real check (bugs slip through), and satisfies both validates and keeps the narrow inferred type — ideal for palettes, route tables, and config records where you want completeness checking plus precise per-key value types. Its one limit is contextual typing: because it runs after inference, it cannot seed un-annotated callback parameters, so reach for an annotation when the target type must drive inference. This closes the functions-and-this unit; you now control overload resolution, the typing and erasure of this, assertion-driven control flow, and the precise difference between annotation, cast, and satisfies. Now when you see as SomeType in a config object or route table, ask whether satisfies would give you the same safety with the added bonus that the literal types survive downstream.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 8 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources1
expand
  1. 01

Trademarks belong to their respective owners. Editorial reference only.