TypeScript's satisfies Operator: Validate Config Objects Without Losing Literal Types
TypeScript's satisfies operator (4.9+) validates config objects against a type while preserving literal inference — no more choosing between typo checking and precise autocomplete.
23 Feb 2026, 10:21 UTC

You write a route table or a theme config as a plain object. You want the compiler to catch typos in it, and you want autocomplete to know that routes.home is exactly the string '/', not just string. For years, TypeScript forced you to pick one: annotate the object and lose the literal types, or leave it unannotated and lose the validation. The satisfies operator, added in TypeScript 4.9, gives you both.
The old trade-off, concretely
Say you have a route map:
const routes = {
home: '/',
about: '/about',
settigns: '/settings', // typo nobody catches
};With no annotation, routes.home infers as string (unless you add as const), and the misspelled key sails through. Add an annotation and the typo gets caught — but now everything widens:
const routes: Record<string, string> = {
home: '/',
about: '/about',
settings: '/settings',
};
// routes.home is typed as string, not '/'
// routes.nonexistent is also "fine" — typed string, undefined at runtimeThe annotation tells the compiler "this value is a Record<string, string>," so downstream code loses the knowledge of which keys actually exist. The third option, an as cast, is worse: as Record<string, string> overrides the compiler instead of asking it to check, so excess or mistyped properties pass silently.
What satisfies does differently
satisfies checks that an expression conforms to a type without changing the expression's inferred type. The validation happens, and the narrow inference survives:
const routes = {
home: '/',
about: '/about',
settings: '/settings',
} satisfies Record<string, string>;
routes.home; // inferred as the literal '/'
routes.settigns; // error: property does not existTwo failure modes are now covered. If you misspell a key inside the object against a stricter target type (say, a union of allowed route names), the compiler flags it. And if you access a key that doesn't exist, that's an error too — something the plain Record annotation happily allowed.
A worked example: feature flags with as const
satisfies composes well with as const, which is the pattern worth adopting for design tokens and flag maps. as const gives you deep readonly literal types; satisfies validates the shape:
type FlagConfig = {
description: string;
enabledByDefault: boolean;
};
const flags = {
newCheckout: {
description: 'Streamlined checkout flow',
enabledByDefault: false,
},
darkMode: {
description: 'Dark theme support',
enabledByDefault: true,
},
} as const satisfies Record<string, FlagConfig>;
type FlagName = keyof typeof flags; // 'newCheckout' | 'darkMode'
function isEnabled(name: FlagName): boolean {
return flags[name].enabledByDefault;
}Ordering matters: as const applies first, then satisfies checks the readonly result against FlagConfig. If someone adds a flag missing enabledByDefault, or writes enabledByDefault: 'yes', the build fails — yet FlagName stays a precise union derived from the actual object, so renaming a flag surfaces every call site.
Trade-offs and limits
satisfies is not a type guard. It validates against the declared type at the point of definition, but it doesn't narrow unions for you later, and it doesn't make an untyped JSON payload safe — for runtime data you still need a schema validator or manual checks. It also requires TypeScript 4.9 or newer; on older compilers the fallback is the identity-function trick (const defineFlags = <T extends Record<string, FlagConfig>>(t: T) => t), which works but adds indirection.
The other risk is noise. Sprinkling satisfies on every object literal adds clutter for no benefit — it pays off specifically where downstream code depends on literal inference: route tables, token maps, flag configs, lookup dictionaries passed to generic functions. A local variable used once and discarded rarely qualifies.
Try it in five minutes
Check your compiler version first — you need 4.9+:
# run in your project root; requires no special permissions
npx tsc --versionThen paste the flags example into a scratch file, hover over flags.newCheckout in your editor, and confirm the type shows the literal shape rather than FlagConfig. Delete the enabledByDefault line and confirm the compiler errors. Finally, swap satisfies for a plain : Record<string, FlagConfig> annotation and hover again — the widening you see is exactly what satisfies avoids. Since the operator is compile-time only and emits no JavaScript, adopting it is zero-cost at runtime and trivially reversible.
The actionable rule: anywhere you currently write as const on a shared config object, add satisfies with the intended shape. You keep the inference you had and gain a compile-time contract you didn't.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.