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.
TL;DR:
value satisfies Typevalidates thatvaluematchesType'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;satisfiesis the missing third option: validates without widening.
The False Choice Before satisfies
interface RouteConfig {
method: string;
path: string;
}
// Option A: explicit annotation. Validates, but widens.
const routeA: RouteConfig = {
method: 'GET',
path: '/users',
};
// routeA.method is typed as `string`, not the literal 'GET'
// so this compiles even though it shouldn't be allowed downstream:
function handleGet(method: 'GET') { /* ... */ }
handleGet(routeA.method); // Error: string 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',
};
Option A validates the shape but loses the literal 'GET' type, routeA.method becomes a general string, which breaks code that specifically needs the narrower literal. Option B keeps the literal types but catches nothing, the typo methdo compiles without complaint because there's no interface checking it against.
The Same Config With satisfies
interface RouteConfig {
method: string;
path: string;
}
const route = {
method: 'GET',
path: '/users',
} satisfies RouteConfig;
// route.method is still the literal type 'GET', not widened to string
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, 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 type for anything downstream that needs it.
Combined With as const for Immutable, Validated Config
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 to literals; satisfies ThemeConfig verifies the shape matches without disturbing that narrowing. This combination is the pattern worth reaching for by default on any config object that's meant to be both validated and immutable, a route table, a theme definition, an environment-variable schema.
A Real Case: 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([]);
}
Without satisfies, typing commands as Record<string, Command> directly would widen keyof typeof commands to string, losing the specific 'build' | 'deploy' union that makes runCommand's parameter meaningfully typed instead of accepting any string. satisfies keeps that union intact while still verifying every command object actually has a description and handler matching the interface.
Old Way vs Modern Way
| 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 |
Conclusion
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.