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.
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.
| Line | TypeScript 5.9.3 | TypeScript 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 error | no error |
{ methdo: ... } satisfies RouteConfig | TS2561, "Did you mean to write 'method'?" | same |
{ method: 'PATCH' } satisfies RouteConfig | TS2322, not assignable to the union | same, union sorted |
handleGet(routeD.method) after satisfies | compiles | compiles |
routes.nope on a satisfies Record<string, RouteConfig> map | TS2339, and the message lists the real keys home and save | same |
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?
| Approach | Validates shape | Preserves literal types |
|---|---|---|
const x: Type = value | Yes | No, widens to Type's declared types |
const x = value as Type | No, silently trusts you | No, forces the asserted type |
const x = value (no annotation) | No | Yes |
const x = value satisfies Type | Yes | Yes |
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 plainstring, 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:
- Reach for
satisfieson any object literal you also read values back out of. - Add
as constin front of it when the object should be immutable and its values should stay literal. - Keep a plain annotation when you genuinely want the wide type, which is most function parameters and most exported API surfaces.
- Use
asonly 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.