Here's a TypeScript scenario that used to drive me insane.
I'd define a route config object — a map of URL paths to their handler metadata. I want TypeScript to validate that every value has the right shape, but I also want autocomplete on the keys and narrow literal types on the values.
type RouteConfig = {
path: string;
component: string;
auth: boolean;
};
// The naive approach with a type annotation:
const routes: Record<string, RouteConfig> = {
"/dashboard": {
path: "/dashboard",
component: "DashboardPage",
auth: true,
},
"/settings": {
path: "/settings",
component: "SettingsPage",
auth: false,
},
};
This works. TypeScript checks that every value matches RouteConfig. But look what happens when you try to use it:
routes["/dashboard"].auth;
// ^? (property) auth: boolean
routes["/settings"].path;
// ^? (property) path: string
The literal types are gone. auth is boolean, not true | false. path is string, not "/dashboard" | "/settings". If you're building a type-safe router, you just lost the information you needed.
Before TypeScript 4.9, there was no clean way out of this. You had two options, and neither was good.
Option 1: Annotate Nothing (Bare Object)
const routes = {
"/dashboard": {
path: "/dashboard",
component: "DashboardPage",
auth: true,
},
"/settings": {
path: "/settings",
component: "SettingsPage",
auth: false,
},
};
routes["/dashboard"].auth;
// ^? (property) auth: true — narrowed!
Great, the types are narrow now. But there's no validation. If someone adds a route with a typo in a field name — say componnet: "ProfilePage" — TypeScript won't blink. You only discover the error at runtime.
Option 2: Use as (Type Assertion)
const routes = {
"/dashboard": {
path: "/dashboard",
component: "DashboardPage",
auth: true,
},
"/settings": {
path: "settings", // oops, missing leading slash
component: "SettingsPage",
auth: false,
},
} as Record<string, RouteConfig>;
The as keyword tells TypeScript "trust me, I know what I'm doing." But that's the problem — as suppresses errors. That missing / in the settings path? Silent. The as assertion validates nothing; it just reassigns the type. And you still lose narrow types.
You had to choose between safety and narrow types. You couldn't have both.
Enter satisfies
The satisfies operator, added in TypeScript 4.9, solves this exact dilemma. It validates that a value's type matches a given type without changing the inferred type of the value.
type RouteConfig = {
path: string;
component: string;
auth: boolean;
};
const routes = {
"/dashboard": {
path: "/dashboard",
component: "DashboardPage",
auth: true,
},
"/settings": {
path: "/settings",
component: "SettingsPage",
auth: false,
},
} satisfies Record<string, RouteConfig>;
Now look at what TypeScript infers:
routes["/dashboard"].auth;
// ^? (property) auth: true — still narrow!
routes["/dashboard"].path;
// ^? (property) path: "/dashboard" — still the literal type!
The narrow literal types are preserved. TypeScript remembers that auth is exactly true for /dashboard and exactly false for /settings. And path is the exact string literal, not the widened string type.
But validation still works. Try adding a typo:
const routes = {
"/dashboard": {
path: "/dashboard",
componnet: "DashboardPage", // typo!
auth: true,
},
} satisfies Record<string, RouteConfig>;
// ^^^^^^^^
// Type '{ componnet: string; ... }' is not assignable to type 'RouteConfig'.
// Object literal may only specify known properties, and 'componnet' does not exist in type 'RouteConfig'.
And missing fields are still caught:
const routes = {
"/dashboard": {
path: "/dashboard",
component: "DashboardPage",
},
} satisfies Record<string, RouteConfig>;
// ❌ Property 'auth' is missing in type '{ path: string; component: string; }' but required in type 'RouteConfig'.
You get the validation of a type annotation and the narrow types of an unannotated value. Both at the same time.
Real-World Pattern: Type-Safe Color Themes
This is where satisfies really shines. Here's a color theme object where you want to ensure every key has the right shape, but also get autocomplete on the color names:
type ThemeColor = {
light: string;
dark: string;
hover?: string;
};
const theme = {
primary: {
light: "#6366f1",
dark: "#818cf8",
hover: "#4f46e5",
},
background: {
light: "#ffffff",
dark: "#0f172a",
},
error: {
light: "#ef4444",
dark: "#f87171",
// hover is optional — no error if missing
},
} satisfies Record<string, ThemeColor>;
// Narrow keys are preserved:
type ThemeKeys = keyof typeof theme;
// ^? type ThemeKeys = "primary" | "background" | "error"
Without satisfies, ThemeKeys would be string. With satisfies, you get the exact union of keys you defined. This powers autocomplete in your IDE when you type theme. — you see primary, background, and error, not just "any string."
Real-World Pattern: API Response Enums
Another common spot: mapping API status codes to display labels while keeping strict typing:
type StatusInfo = {
label: string;
color: "green" | "yellow" | "red";
actionable: boolean;
};
const statusMap = {
active: { label: "Active", color: "green", actionable: true },
pending: { label: "Pending", color: "yellow", actionable: false },
suspended: { label: "Suspended", color: "red", actionable: true },
archived: { label: "Archived", color: "gray", actionable: false },
// ❌ "'gray' is not assignable to parameter of type 'green' | 'yellow' | 'red'"
} satisfies Record<string, StatusInfo>;
That "gray" value is caught at compile time. Without satisfies, you'd only discover the invalid color when it rendered on screen with a broken CSS class.
Real-World Pattern: Function Dispatch Maps
This is my personal favorite. Dispatch maps are objects where you look up a function by a key and call it. satisfies preserves the return type and parameter types of each function individually:
type EventHandler = (...args: any[]) => void | Promise<void>;
const handlers = {
user_created: (payload: { id: string; email: string }) => {
console.log(`New user: ${payload.email}`);
},
order_shipped: (payload: { orderId: string; tracking: string }) => {
console.log(`Order ${payload.orderId} shipped`);
},
payment_failed: (payload: { amount: number; reason: string }) => {
console.error(`Payment failed: ${payload.reason}`);
},
} satisfies Record<string, EventHandler>;
Now typeof handlers preserves the specific parameter payload types for each handler — TypeScript knows handlers.user_created expects { id: string; email: string } and handlers.payment_failed expects { amount: number; reason: string }. Meanwhile satisfies guarantees each one is a valid function that returns void or Promise<void>. If you accidentally add a non-function value, TypeScript catches it. If you add a handler that returns a number, TypeScript catches that too.
When NOT to Use satisfies
satisfies is not a universal replacement for type annotations or assertions. Here's when to stick with the alternatives:
Use a type annotation when: You want the value to actually become that type. If downstream code needs routes to be typed as Record<string, RouteConfig> (because an API expects that exact type), use a type annotation. satisfies only validates — it doesn't change the type.
Use as when: You know something TypeScript doesn't. If you're parsing JSON.parse() output or interacting with an untyped API, as is your escape hatch. But that's literally the only valid use case.
Use satisfies when: You want validation AND narrow types. This covers most everyday code — configs, maps, enums, themes, event handlers, route definitions. Anywhere you're defining a constrained object and want to preserve its literal types.
Quick Reference
| Feature | Type Annotation (: Type) |
Type Assertion (as Type) |
satisfies |
|---|---|---|---|
| Validates structure | ✅ | ❌ (blindly trusts) | ✅ |
| Preserves narrow types | ❌ (widens) | ❌ (widens) | ✅ |
| Catches extra properties | ✅ | ❌ | ✅ |
| Use for parsing unknown data | ❌ | ✅ | ❌ |
| Changes the value's type | ✅ (widens) | ✅ (overrides) | ❌ (validates only) |
The Bottom Line
satisfies fills a gap that existed in TypeScript for years. Before it, every config object, enum map, and theme definition forced you to choose between type safety and useful types. Now you don't have to compromise.
Next time you reach for a type annotation on an object literal, ask yourself: do I actually want to widen this? Or do I just want to validate it? If the answer is the latter, satisfies is what you're looking for.
Top comments (0)