The satisfies operator shipped in TypeScript 4.9 and remains one of the most underused features in the language. Most codebases that could benefit from it are still reaching for type annotations — which widen types — or as assertions — which lie to the compiler. satisfies threads the needle: it validates that a value conforms to a type without changing what the compiler knows about that value's specific shape.
Understanding the distinction between validation and widening unlocks a class of patterns that make config objects, lookup tables, and registries genuinely safer and more expressive.
The Widening Problem with Type Annotations
When you annotate a variable, TypeScript uses that annotation as the source of truth. Any information narrower than the annotation is discarded — permanently.
// ❌ Annotation widens — literal types are gone
const config: Record<string, { method: "GET" | "POST" | "PUT" | "DELETE" }> = {
getUser: { method: "GET" },
createUser: { method: "POST" },
};
// config.getUser.method is "GET" | "POST" | "PUT" | "DELETE"
// You can't use it where only "GET" is accepted without another assertion
// ✅ satisfies validates the shape and preserves literals
const config = {
getUser: { method: "GET" },
createUser: { method: "POST" },
} satisfies Record<string, { method: "GET" | "POST" | "PUT" | "DELETE" }>;
// config.getUser.method is "GET" — the literal is preserved
type GetMethod = typeof config.getUser.method; // "GET"Same structural validation. Richer types downstream. That's the core trade-off.
Where This Matters in Practice
Route registries are the canonical example. A codebase that generates typed API clients or uses routing tables for permission checks needs more than shape validation — it needs the literal values to survive.
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
interface RouteDefinition {
path: string;
method: HttpMethod;
auth: boolean;
}
// ❌ After this annotation, specificity is gone
const ROUTES: Record<string, RouteDefinition> = {
listUsers: { path: "/users", method: "GET", auth: true },
createUser: { path: "/users", method: "POST", auth: true },
deleteUser: { path: "/users/:id", method: "DELETE", auth: true },
};
// ROUTES.listUsers.method is HttpMethod — you've lost "GET"
// Every consumer has to assert or runtime-check method values
// ✅ Shape validated, literals preserved
const ROUTES = {
listUsers: { path: "/users", method: "GET", auth: true },
createUser: { path: "/users", method: "POST", auth: true },
deleteUser: { path: "/users/:id", method: "DELETE", auth: true },
} satisfies Record<string, RouteDefinition>;
// ROUTES.listUsers.method is "GET"
// ROUTES.createUser.method is "POST"
type ListMethod = typeof ROUTES.listUsers.method; // "GET"When you generate typed fetch wrappers, OpenAPI schemas, or permission maps from this registry, the literal types become part of your type-level API surface — no runtime checks needed.
satisfies vs as: Not the Same Thing
as is an assertion — you're telling the compiler to trust you regardless of what it sees. It suppresses errors; it does not validate. Confusing the two is how type-level bugs make it to production.
// ❌ as bypasses validation — the compiler accepts nonsense
const broken = {
getUser: { method: "INVALID_METHOD" },
} as Record<string, RouteDefinition>; // No error, but wrong at runtime
// ✅ satisfies catches the mistake at compile time
const broken = {
getUser: { method: "INVALID_METHOD" },
} satisfies Record<string, RouteDefinition>;
// Error: Type '"INVALID_METHOD"' is not assignable to type 'HttpMethod'Reserve as for cases where you genuinely know something the compiler cannot — a DOM query result where the element type is certain, or a deserialized payload after explicit runtime validation. Use satisfies when you want validation with preserved inference.
Composing with as const
When you need literal preservation and deep immutability, as const and satisfies compose cleanly.
const STATUS_CODES = {
ok: 200,
created: 201,
noContent: 204,
badRequest: 400,
unauthorized: 401,
notFound: 404,
serverError: 500,
} as const satisfies Record<string, number>;
// Every value is its literal numeric type: 200, 201, 204...
// AND TypeScript enforces that all values are numbers
type OkCode = typeof STATUS_CODES.ok; // 200 — not number
function isSuccess(code: typeof STATUS_CODES[keyof typeof STATUS_CODES]): boolean {
return code >= 200 && code < 300;
}Order matters: write as const satisfies T, not satisfies T as const. TypeScript evaluates left to right — as const narrows to literals first, then satisfies validates the narrowed type against the constraint.
Keeping Discriminated Unions Precise
satisfies is particularly useful when each entry in a lookup object is a discriminated union member and you need the discriminant literal to survive downstream.
type NotificationHandler =
| { type: "email"; to: string; subject: string }
| { type: "sms"; to: string }
| { type: "push"; deviceToken: string; title: string };
// ❌ Annotation collapses discriminants to the full union
const handlers: Record<string, NotificationHandler> = {
email: { type: "email", to: "", subject: "" },
sms: { type: "sms", to: "" },
push: { type: "push", deviceToken: "", title: "" },
};
// handlers.email.type is "email" | "sms" | "push"
// ✅ satisfies keeps each member precise
const handlers = {
email: { type: "email", to: "", subject: "" },
sms: { type: "sms", to: "" },
push: { type: "push", deviceToken: "", title: "" },
} satisfies Record<string, NotificationHandler>;
// handlers.email.type is "email"
// handlers.sms.type is "sms"
// Downstream switch statements can prove exhaustiveness without assertionsThis matters when handler objects flow into functions that dispatch on type. With a widened annotation, the compiler can't help. With satisfies, it can prove each branch is reachable and the switch is exhaustive.
A Full Pattern: Type-Safe Feature Flag Registry
Here's a production-grade pattern that puts everything together. The registry validates structure, preserves literals, and derives its key union automatically.
interface FeatureFlag {
defaultValue: boolean;
description: string;
rolloutPercentage: number;
}
const FLAGS = {
newDashboard: {
defaultValue: false,
description: "Enables the redesigned analytics dashboard",
rolloutPercentage: 0,
},
streamingExport: {
defaultValue: true,
description: "Uses streaming for large CSV exports",
rolloutPercentage: 100,
},
betaSearch: {
defaultValue: false,
description: "Experimental vector-powered search",
rolloutPercentage: 10,
},
} satisfies Record<string, FeatureFlag>;
// Derived automatically — no manual union to maintain
type FlagName = keyof typeof FLAGS;
// "newDashboard" | "streamingExport" | "betaSearch"
function isEnabled(flag: FlagName, userPercentile: number): boolean {
const { defaultValue, rolloutPercentage } = FLAGS[flag];
return defaultValue || userPercentile <= rolloutPercentage;
}FlagName updates whenever FLAGS changes. Add a key — the union expands. Remove one — any stale reference breaks at compile time. A malformed flag entry is a compile error, not a runtime surprise discovered in a staging deploy.
Key Takeaways
- Annotations widen,
satisfiespreserves — usesatisfieswhen you want shape validation and want downstream code to see literal types. satisfiesvalidates;assuppresses — never useasto paper over a shape mismatch thatsatisfieswould correctly reject.as const satisfies Tis the full combo — immutable literals that are still validated against a structural constraint.- Derive key unions from registries —
keyof typeof myObjectaftersatisfiesgives a precise, self-maintaining union with no manual sync required. - Discriminated union members stay narrow — literal discriminants survive, making downstream
switchstatements fully exhaustive without extra assertions.



