Skip to content

How satisfies Gives You Inference and Validation Both

Typing a config object either validates its shape or preserves literal inference, never both, until satisfies. Here's exactly what trade-off it removes.

· · 10 min read

Updated: October 6, 2026

A computer screen displaying code

Quick Take

`value satisfies Type` validates that `value` matches `Type`'s shape at compile time, exactly like a type annotation would, but without changing the variable's inferred type afterward. A plain annotation (`const x: Type = value`) validates and widens; a type assertion (`value as Type`) widens without validating; `satisfies` is the missing third option: validates without widening.

Run this yourself. The examples that compile and the ones that must not, all asserted: typescript-satisfies-operator/ in the Coding Dunia code-examples repo.

The config object is the case that comes up in nearly every project: a plain object literal that needs to match some interface (correct keys, correct value shapes) while also keeping its most specific inferred types available for downstream code, like a routing config where each route's method needs to stay 'GET' | 'POST', not widen to string.

What Was the False Choice Before satisfies?

interface RouteConfig {
  method: 'GET' | 'POST' | 'PUT' | 'DELETE';
  path: string;
}

// Option A: explicit annotation. Validates, but widens.
const routeA: RouteConfig = {
  method: 'GET',
  path: '/users',
};
// routeA.method is typed as the whole declared union, not the literal 'GET'
// so the narrower call below stops compiling:
function handleGet(method: 'GET') { /* ... */ }
handleGet(routeA.method); // Error TS2345: 'GET' | 'POST' | 'PUT' | 'DELETE' is not assignable to 'GET'

// Option B: no annotation. Preserves literal types, but no validation.
const routeB = {
  methdo: 'GET', // typo, no error, this key is silently wrong
  path: '/users',
};

Before satisfies, every config object forced a choice between two half-answers. Option A validates the shape but widens routeA.method back to the whole declared union, which breaks any code that needs the narrower 'GET' literal. Option B keeps the literal types but catches nothing, so the typo methdo compiles without complaint because no interface is checking against it.

Neither problem is theoretical. Option A's widening is why a route table typed against an interface stops working with a discriminated union downstream: the discriminant widened back to the declared type somewhere in the middle and nothing flagged it. Option B's silence is worse, because the typo'd key sits in the object as an excess property and the code that reads route.method gets undefined at runtime with a clean compile.

The workaround most codebases landed on was a generic helper function, defineRoute<T extends RouteConfig>(r: T): T. It works, and it means every config in the project is wrapped in a function that exists only to satisfy the type checker.

What Does the Same Config Look Like With satisfies?

interface RouteConfig {
  method: 'GET' | 'POST' | 'PUT' | 'DELETE';
  path: string;
}

const route = {
  method: 'GET',
  path: '/users',
} satisfies RouteConfig;

// route.method is still the literal type 'GET', not the declared union
function handleGet(method: 'GET') { /* ... */ }
handleGet(route.method); // Compiles fine, route.method is 'GET'

const routeWithTypo = {
  methdo: 'GET', // Error: Object literal may only specify known properties,
  path: '/users', //        and 'methdo' does not exist in type 'RouteConfig'
} satisfies RouteConfig;

satisfies RouteConfig checks the object literal against the interface at the point of declaration, so the typo is caught immediately, exactly like Option A. But route's inferred type afterward is its own literal shape, { method: 'GET'; path: '/users' }, not widened to RouteConfig, so route.method keeps its narrow 'GET' literal for anything downstream.

One detail that costs people an afternoon, and I lost one to it myself: satisfies does not switch widening off. It stops the annotation from widening, but the type you satisfy still supplies a contextual type. Declare method: string in RouteConfig instead of the union above, and route.method comes back as string even with satisfies on the end, because string is what the context asked for. Two things keep the literal: a satisfied type narrower than string, as here, or as const satisfies, which the next section covers. The runnable folder linked at the top of this page asserts both, so you can watch the widening case fail rather than believe me.

The operator sits at the end of the expression rather than in the type position, and that placement is the whole design. A type annotation on the left tells TypeScript what the variable is. satisfies on the right tells TypeScript what to verify, then gets out of the way and lets inference do its normal job.

TypeScript 4.9 shipped it in November 2022, which makes it available in every version anyone is realistically running in 2026. There is no flag to enable and no runtime output; like every other type-level construct, it vanishes entirely at compile time.

How Do You Combine satisfies With as const?

interface ThemeConfig {
  colors: Record<string, string>;
  spacing: Record<string, number>;
}

const theme = {
  colors: { primary: '#2563eb', secondary: '#64748b' },
  spacing: { sm: 8, md: 16, lg: 24 },
} as const satisfies ThemeConfig;

// theme.colors.primary is the literal '#2563eb', readonly
// and TypeScript verified the whole shape matches ThemeConfig

as const makes every property readonly and narrows values to literals; satisfies ThemeConfig verifies the shape without disturbing that narrowing. The combination is the pattern worth reaching for by default on any config meant to be both validated and immutable: a route table, a theme definition, an environment-variable schema.

Order matters, and it's the one thing people get wrong. as const satisfies ThemeConfig narrows first and then validates the narrowed type. Reverse it and you validate the wide type, then narrow, which still compiles but checks something less specific than what you actually shipped.

The payoff shows up at the call site. With this theme, theme.spacing.md has the literal type 16, so a function expecting one of your defined spacing values accepts it and rejects an arbitrary number, without you writing a union by hand.

How Does satisfies Work in a Typed Command Registry?

interface Command {
  description: string;
  handler: (args: string[]) => Promise<void>;
}

const commands = {
  build: {
    description: 'Build the project',
    handler: async (args) => { /* ... */ },
  },
  deploy: {
    description: 'Deploy to production',
    handler: async (args) => { /* ... */ },
  },
} satisfies Record<string, Command>;

// TypeScript knows the exact keys: 'build' | 'deploy'
type CommandName = keyof typeof commands;

function runCommand(name: CommandName) {
  return commands[name].handler([]);
}

A command registry is where the difference stops being academic. Typing commands as Record<string, Command> directly would widen keyof typeof commands to string, losing the 'build' | 'deploy' union that makes runCommand's parameter meaningfully typed rather than accepting any string at all. satisfies keeps that union intact while still verifying every command object has a description and a handler matching the interface.

This is the pattern I reach for most, because the alternative is maintaining the key union by hand in a second place and watching it drift. Add a test command to the object and CommandName updates itself; forget to add it to a hand-written union and you get an error at the call site instead of at the definition.

The same shape works for anything keyed by a known set: feature flags, event maps, reducer action handlers, CLI subcommands. On one internal CLI I moved over, this removed three hand-maintained union types and the two places where they had already drifted out of sync with the objects they described.

satisfies removes a trade-off that shouldn't have existed: validating an object's shape used to mean giving up the precise literal types that made the object useful.

Share this Post on X Bluesky

What Does the Compiler Actually Say?

I put every snippet above into one file and compiled it with strict: true under TypeScript 5.9.3 and 7.0.2. Same file, same config, two compilers. Here's the full output for the lines that matter.

LineTypeScript 5.9.3TypeScript 7.0.2
handleGet(routeA.method) (annotation, widened)TS2345, argument is "GET" | "POST" | "PUT" | "DELETE"TS2345, argument is "DELETE" | "GET" | "POST" | "PUT"
routeB with methdo (no annotation)no errorno error
{ methdo: ... } satisfies RouteConfigTS2561, "Did you mean to write 'method'?"same
{ method: 'PATCH' } satisfies RouteConfigTS2322, not assignable to the unionsame, union sorted
handleGet(routeD.method) after satisfiescompilescompiles
routes.nope on a satisfies Record<string, RouteConfig> mapTS2339, and the message lists the real keys home and savesame

Two takeaways. The behavior didn't move between 5.9 and 7.0, so a codebase that leans on satisfies migrates without surprises there. What did move is the text: TypeScript 7 prints union members in sorted order, not declaration order, so the first mismatch it names is "DELETE" where 5.9 names "POST". If you grep CI logs or snapshot-test compiler messages, that will bite you; I'd rather have found that here than in a failing pipeline.

The other thing I didn't expect is how much the Did you mean to write 'method'? suggestion is worth. Option B, the unannotated object, compiled without a single diagnostic. That's the typo class this operator exists to catch, and the compiler even proposes the fix. It's the strongest argument for satisfies in a config file, and it's cheaper than any helper function.

Which Approach Should You Reach For?

ApproachValidates shapePreserves literal types
const x: Type = valueYesNo, widens to Type's declared types
const x = value as TypeNo, silently trusts youNo, forces the asserted type
const x = value (no annotation)NoYes
const x = value satisfies TypeYesYes

Only the last row of that table gives you both columns, which is the entire argument for the operator. The other three each fail on one side: an annotation validates and widens, an assertion neither validates nor preserves, and a bare declaration preserves without validating.

Three terms in the table get used loosely and mean different things:

  • Widening is TypeScript replacing a literal type with the wider type some declaration asked for, turning 'GET' into 'GET' | 'POST' | 'PUT' | 'DELETE', or into plain string, the moment a declared type says so.
  • Assertion is as Type, which changes what the compiler believes without checking anything. It is a claim, not a validation.
  • Satisfaction is satisfies Type, which checks the claim and then leaves the inferred type alone.

A rule of thumb that covers nearly every case:

  1. Reach for satisfies on any object literal you also read values back out of.
  2. Add as const in front of it when the object should be immutable and its values should stay literal.
  3. Keep a plain annotation when you genuinely want the wide type, which is most function parameters and most exported API surfaces.
  4. Use as only when you know something the compiler cannot, and leave a comment saying what.

The third rule is the one people skip on the way to using satisfies everywhere. An exported function signature is a contract, and narrowing it to whatever literal happened to be passed makes that contract accidentally strict: callers who pass a perfectly valid string get an error because your parameter inferred 'GET'. Keep annotations where you want the wide type, and reserve satisfies for the values you also read back.

What Should You Take Away From This?

satisfies doesn't add new type-checking power, it removes a trade-off that shouldn't have existed: validating an object's shape against an interface used to mean giving up the precise literal types that made the object useful for anything beyond that validation. For any config, route table, or lookup object where both matter, satisfies is the version to reach for by default.

Frequently Asked Questions

What problem does the satisfies operator solve?
Before satisfies, typing a variable with an explicit annotation (const config: Config = {...}) validated its shape against Config but widened literal types to their general form, a color: 'blue' as string, not the literal 'blue'. Leaving the annotation off preserved literal inference but gave up shape validation entirely. satisfies validates the value against a type without changing the inferred type of the variable itself, giving you both at once.
How is satisfies different from a type assertion (as)?
as tells the compiler to trust you and silently overrides the inferred type, with no validation, if the value doesn't actually match the asserted type, TypeScript won't catch it. satisfies is validated: if the value doesn't structurally match the type, TypeScript raises a compile error, exactly like a normal type annotation does. The difference from a normal annotation is that satisfies doesn't change what type the variable is inferred as afterward; the value keeps its own most specific inferred type.
Can satisfies be used with as const together?
Yes, and that combination is one of the more common patterns: value satisfies Type as a means to validate the value's shape while still keeping the literal inference and readonly nature that as const provides. They serve different purposes, as const makes properties readonly and narrows to literals, satisfies validates shape without widening, and using both together gets you validated, literal, immutable config in one expression.