TypeScript satisfies operator: validate without widening

Last updated: May 18, 2026

Most TypeScript developers reach for satisfies expecting a smarter annotation that narrows types automatically, but the operator does not infer narrower types at all — it refuses to widen the type the compiler would already infer for the right-hand expression, then throws the constraint away. That distinction feels like quiet magic on a const-bound object literal and like silent widening on a mutable property or any source that arrived widened. The mechanism is closer to validate-without-rewriting than to infer-more-tightly, and that single sentence prevents most of the bugs people ship with it.

Quick nav: What satisfies actually does in one sentence · The mechanism: contextual typing, assignability check, constraint discarded · Why satisfies looks like narrowing but isn’t · The as const satisfies T composition rule · A four-tool decision rubric · Where satisfies is redundant · Typed route tables and discriminated-union variants · Zero runtime cost, confirmed via emit · What the sources prove

  • Shipped: TypeScript 4.9 (November 2022) via PR microsoft/TypeScript#46827.
  • Annotation vs. satisfies: annotation replaces the inferred type with the annotated one; satisfies validates against the constraint and keeps the inferred type.
  • Assertion vs. satisfies:as T can lie about types; satisfies T cannot — non-assignability is a compile error at the operator’s position.
  • Composition rule:as const satisfies T is the ordering documented for deeply-readonly validated literals.
  • Runtime cost: zero. The operator is erased during emit and appears in neither the generated .js nor the .d.ts.
Opening visual showing the hidden path in Inside the satisfies Operator: How It Differs From Type Assertions and Annotations

The hidden path behind the headline, animated.

The visual sits exactly on the distinction this article defends: a clamp checks a shape without redrawing it, the lens beside it represents the annotation that would replace the value with whatever it sees. Holding both in mind clarifies why two snippets that look almost identical produce different hover types — one tool checks, the other rewrites.

What satisfies actually does in one sentence

The satisfies operator validates that an expression is assignable to a given type without changing the expression’s inferred type. Annotations replace the inferred type with the annotated one. Assertions (as T) silence the check entirely. A four-line side-by-side makes the difference legible:

type Config = { mode: 'dev' | 'prod'; port: number };

const a: Config             = { mode: 'dev', port: 3000 }; // hover: Config
const b = { mode: 'dev', port: 3000 } as Config;            // hover: Config (unchecked at the cast)
const c = { mode: 'dev', port: 3000 } satisfies Config;     // hover: { mode: 'dev'; port: number }
const d = { mode: 'dev', port: 3000 };                       // hover: { mode: string; port: number }

The hover types tell the whole story. The annotated value loses access to the literal 'dev' at the type level. The asserted value claims to be Config whether it is or not. The satisfies form keeps mode: 'dev' on a property the compiler treated as a literal in context, while still erroring at the operator if the value were not a valid Config.

I wrote about lying with type assertions if you want to dig deeper.

The mechanism: contextual typing, assignability check, constraint discarded

Behaviour becomes predictable once the three internal steps are named. First, the constraint type provides a contextual type for the right-hand expression — the same machinery the compiler uses to type the body of a function parameter against the parameter’s annotated type. Second, the resulting type is checked for assignability to the constraint; if it fails, the diagnostic is emitted at the satisfies position. Third, the constraint is discarded for typing purposes, and the expression’s now-contextualised inferred type becomes the type of the surrounding declaration.

Terminal output for Inside the satisfies Operator: How It Differs From Type Assertions and Annotations
Real output from a sandboxed container.

The terminal output above illustrates the same three-step model on a small failing case. A non-assignable property — mode: 'staging' against Config['mode'] being 'dev' | 'prod' — produces an error at the satisfies position, not at the use site. The check fires; the constraint just does not survive into the binding’s declared type.

For more on this, see how inference flows through context.

This three-step model is the one the implementing change ships. The satisfies operator implementation pull request describes the operator as a type assertion that, instead of changing the type of the expression, requires the type of the expression to match the type — assignability checked, type preserved.

Why satisfies looks like narrowing but isn’t

The common reading — that satisfies narrows the type — falls apart on two everyday cases: a mutable property, and a source expression that already arrived widened. The first case:

type Palette = Record<'red' | 'green', string | [number, number, number]>;

const p = {
  red:   [255, 0, 0],
  green: '#0f0',
} satisfies Palette;

p.red;          // hover: [number, number, number]
p.green.length; // OK — green is a string

So far this matches the canonical example everyone shows. The narrowness on each property survives because each property is being typed at its declaration position and the contextual type permits the tuple form. Change one thing — move red to an outer binding first — and the magic evaporates:

There is a longer treatment in runtime narrowing with guards.

const red = [255, 0, 0];                  // inferred: number[]
const p2 = { red, green: '#0f0' } satisfies Palette;
//          ~~~ Type 'number[]' is not assignable to type 'string | [number, number, number]'.

Contextual typing influences inference at the position where the expression is being typed; an outer binding has already been typed before the property literal exists. The constraint did not narrow red into a tuple, because red was already number[] before satisfies ran. Use a tuple type for red and a tuple-typed input wins; use a wider source and the wider type survives, possibly all the way to a hard error.

The mutable-property variant bites differently. Bind with let instead of const and the compiler widens property types to permit reassignment. p.green = 'anything' needs to compile, so green is typed string, not '#0f0'. satisfies does not override this — it sits on top of inference, and inference said string. The literal is gone the moment a discriminant check needs it.

The as const satisfies T composition rule

The composition pattern that hardens this is as const satisfies T, in that order. as const instructs inference to treat the entire expression as a deeply-readonly object/tuple literal whose property types are the literal types of their initialisers. satisfies T then validates that deeply-readonly form against the constraint. The result: properties stay literal, arrays become readonly tuples, and the assignment is checked.

Official documentation for typescript satisfies operator deep dive
The primary source for this topic.

The handbook page on the operator illustrates this composition directly. Putting as const first treats it as an inference directive on the literal itself, and satisfies then validates the now-readonly type — which is the form that preserves literal property types in a way you can build a discriminated union or typeof projection on:

I wrote about const type parameters for exact inference if you want to dig deeper.

// preserved literals + validated
const routesA = {
  home:  { method: 'GET',  path: '/' },
  users: { method: 'POST', path: '/users' },
} as const satisfies Record<string, { method: 'GET' | 'POST'; path: string }>;

routesA.home.method; // hover: 'GET'

A four-tool decision rubric

Engineers usually pick between four tools when attaching a type to an expression: annotation, assertion, satisfies, and a generic constraint on a helper function. Each is correct in a different cell of the matrix below. The question is not “which is best” but “which intent are you encoding”: do you want to constrain the value to a shape, and do you want to preserve the narrower inferred type?

Topic diagram for Inside the satisfies Operator: How It Differs From Type Assertions and Annotations
Purpose-built diagram for this article — Inside the satisfies Operator: How It Differs From Type Assertions and Annotations.

The diagram captures the same axes the table makes explicit — validation on one axis, preservation of the narrower inferred type on the other. The combination determines which tool encodes the intent without lying.

If you need more context, when assertions are unavoidable covers the same ground.

Decision matrix for type-attachment tools in TypeScript
Goal Preserve narrow inferred type Replace with stated shape
Validate assignability satisfies T (often as const satisfies T) Type annotation : T
Skip validation No tool needed — bare expression keeps its inferred type Type assertion as T (use sparingly)
Validate and wrap behaviour around the value Generic constraint: function defineConfig<T extends Config>(c: T): T

The generic-constraint row is the one most write-ups skip. A helper function with a T extends Constraint parameter behaves identically to satisfies at the call site: contextual typing pushes the constraint inward, assignability is checked, and the inferred type is preserved as the return type. The difference is the helper can wrap behaviour around the value — freezing it, registering it, running a transform — while satisfies is purely a type-system gesture.

The matrix also names the bug shipped by picking wrong: annotate when you wanted preservation and literal types vanish at use sites; assert when you wanted validation and the value silently drifts from the contract; reach for satisfies when a runtime helper was actually needed and you have no place to do that work; write a generic helper when a plain satisfies would have done and you have built ceremony around nothing.

Where satisfies is redundant

Contextual typing already handles a surprising number of positions. Return values of functions with explicit return types, function arguments at typed parameters, and barrel-export object literals annotated with their shape are already typed against their declared type, so a redundant satisfies on them changes nothing observable except hover output. Three positions where the operator is doing no work:

// 1. Return value with an explicit return type — return position is already contextually typed.
function getConfig(): Config {
  return { mode: 'dev', port: 3000 } satisfies Config; // redundant
}

// 2. Function argument with a parameter type — argument is already contextually typed.
declare function loadConfig(c: Config): void;
loadConfig({ mode: 'dev', port: 3000 } satisfies Config); // redundant

// 3. Exported binding whose declared type is Config — annotation pulls the type through.
export const config: Config = { mode: 'dev', port: 3000 }; // already validated

In every case above, validation flows from contextual typing alone. satisfies adds a second check at the same position. The cost is small but real on large literals — every extra assignability check is more work for the compiler and the language service. The win condition for the operator is when the binding does not have an annotation upstream, and the goal is to validate without writing the annotation that would discard the narrow type.

For more on this, see composing shapes with intersections.

Typed route tables and discriminated-union variants

The example the operator deserves is not a colour palette. It is a route table or a discriminated-union variant store — places where the inferred narrowness changes downstream type-level work.

Architecture diagram for Inside the satisfies Operator: How It Differs From Type Assertions and Annotations

How the pieces connect.

More detail in mapped type machinery.

The architecture sketch above shows routes as a literal map whose property names key into per-route metadata, with type-level projections — by method, by path, by handler signature — built on top. Only the as const satisfies T form keeps the discriminants those projections depend on.

type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Handler = (req: Request) => Response | Promise<Response>;

type Route = { method: Method; path: string; handler: Handler };

const routes = {
  listUsers:  { method: 'GET',    path: '/users',     handler: () => new Response() },
  createUser: { method: 'POST',   path: '/users',     handler: () => new Response() },
  getUser:    { method: 'GET',    path: '/users/:id', handler: () => new Response() },
  deleteUser: { method: 'DELETE', path: '/users/:id', handler: () => new Response() },
} as const satisfies Record<string, Route>;

type ListUsersMethod = typeof routes.listUsers.method; // 'GET'

// A discriminated projection over method survives:
type GetRoutes = {
  [K in keyof typeof routes]: typeof routes[K]['method'] extends 'GET' ? K : never
}[keyof typeof routes];
// GetRoutes = 'listUsers' | 'getUser'

Annotate the same value with : Record<string, Route> and typeof routes.listUsers.method collapses to Method. The downstream GetRoutes projection can no longer tell the GETs apart from the POSTs. as const satisfies T preserves the discriminant the projection needs; satisfies T alone — without as const — loses it for any property that is not already literal-typed in context.

Discriminated-union variant construction follows the same logic:

type Event =
  | { kind: 'click'; x: number; y: number }
  | { kind: 'key'; code: string };

const e = { kind: 'click', x: 10, y: 20 } satisfies Event;
e.x; // OK — inferred as the click variant; no narrowing needed

const f: Event = { kind: 'click', x: 10, y: 20 };
// f.x;  // Error — `f` is the union; narrow first

This is the position where satisfies changes how code is written. The constraint says the value must be one of the union members; the inferred type stays the specific variant, so subsequent property access does not require a type guard. The annotation form forces a guard at every use site.

Zero runtime cost, confirmed via emit

The operator is a compile-time gesture. Running tsc --emitDeclarationOnly against any of the snippets above produces a .d.ts that contains the inferred type and nothing else; running plain tsc produces a .js where satisfies has been erased. A single line of input — const x = { mode: 'dev', port: 3000 } satisfies Config; — compiles to:

const x = { mode: 'dev', port: 3000 };
Dashboard: satisfies Operator
Profile view of satisfies Operator.

The visual above sketches the type-checking work involved in three forms — annotation, plain satisfies, and as const satisfies T — without claiming a measured delta. Qualitatively, an as const object increases the number of literal types the checker has to track, and large literal types can affect checker throughput and editor hover responsiveness on big config files. On a route table of a few dozen entries any difference is unlikely to be noticeable; on a generated table of a few thousand it is something worth profiling with tsc --extendedDiagnostics before assuming the pattern is free.

The folklore that satisfies is slower than annotation deserves the same caveat. Both perform an assignability check at the same position. as const satisfies T performs more work than either alone because it forces literal-type inference across the whole expression first. None of this changes generated JavaScript — the cost lives in the checker, not the runtime.

What the sources prove

This article compares four type-attachment tools — annotation, assertion, satisfies, and a generic helper — on two axes: does the compiler validate the value against the stated shape, and does the binding keep the narrower inferred type. Every cell of the decision matrix was derived from how the operator is specified to behave, then matched against the documented behaviour on the official handbook pages. The methodology is source-driven, not benchmark-driven; readers running large config files of their own should profile before trusting any cost claim here as authoritative.

What was verified against primary sources. The three-step mechanism (contextual typing flows the constraint inward, an assignability check runs at the operator’s position, the constraint is discarded for the resulting type) is the model the implementing pull request #46827 describes — an operator that “requires the type of the expression to match the type” without changing it.

What was compared, not measured. The check-time discussion above describes the shape of the cost — annotation and plain satisfies both run a single assignability check, while as const satisfies T additionally forces literal-type inference across the whole expression — not a fresh benchmark run. No new tsc --extendedDiagnostics trace was produced for this article, and no claim is made here about absolute numbers or stable relative ordering between forms across compiler versions. The recommendation to profile on your own route table is the honest version of “your numbers will differ from any I could publish.”

What was excluded. Editor performance under tsserver, behaviour inside .d.ts projects with declaration emit only, and behaviour of satisfies inside JSDoc-annotated JavaScript (// @ts-check) are out of scope. The operator works in JSDoc form too, but the failure modes there interact with how JSDoc parses type expressions and merit their own write-up. Third-party type-checker forks (Effect’s micro-checker variants, the various ts-node alternatives) are also excluded; the claims above are about the reference TypeScript compiler.

Reach for satisfies when the binding does not already have a contextual type and the goal is to validate a literal without flattening its narrow shape. Combine it with as const — in that order — when the value should be deeply readonly and its literal property types need to survive into projections, discriminated unions, or typeof queries. Skip it when contextual typing already does the job: barrel exports with declared types, typed return positions, and typed parameter positions. The single sentence to keep in mind is the one this whole article defends — satisfies checks the shape; it does not redraw the value.

References

Anya Sharma

Learn More →

Leave a Reply

Your email address will not be published. Required fields are marked *