The satisfies Operator
The Problem satisfies Solves
For years, TypeScript forced you into an annoying tradeoff: either annotate a variable's type (and lose specific inference) or skip the annotation (and lose validation). You couldn't have both. The satisfies operator (TypeScript 4.9+) finally fixes this — it validates that an expression matches a type without widening the inferred type.
Think of satisfies as a quality inspector on the assembly line. A type annotation says "this MUST be exactly this shape" — it replaces what you know about the item. The satisfies operator says "check that this MEETS the requirements, but remember everything specific about it." The item passes inspection but keeps its detailed label.
satisfies vs Type Annotation vs as
type Color = "red" | "green" | "blue";
type Config = Record<string, Color>;
// 1. Type annotation — VALIDATES but WIDENS
const config1: Config = {
primary: "red",
secondary: "blue",
};
config1.primary; // type: Color (widened — lost "red")
// config1.tertiary; // No error — Record<string, Color> allows any string key
// 2. No annotation — PRESERVES but DOESN'T VALIDATE
const config2 = {
primary: "red",
secondary: "blue",
};
config2.primary; // type: string (inferred — lost Color validation)
// config2.primary could be "purple" — no validation
// 3. satisfies — VALIDATES AND PRESERVES
const config3 = {
primary: "red",
secondary: "blue",
} satisfies Config;
config3.primary; // type: "red" (preserved literal type!)
// config3.tertiary; // ERROR — only known keys exist
// config3.primary = "purple"; // ERROR — "purple" is not a Color
The key insight: satisfies validates the shape matches Config while preserving that primary is specifically "red", not the wider Color type.
Where satisfies Shines
Turns out, satisfies is perfect for exactly the patterns you use every day.
Configuration Objects
type Route = {
path: string;
component: string;
auth?: boolean;
};
type Routes = Record<string, Route>;
// With satisfies — validated + specific
const routes = {
home: { path: "/", component: "HomePage" },
dashboard: { path: "/dashboard", component: "Dashboard", auth: true },
settings: { path: "/settings", component: "Settings", auth: true },
} satisfies Routes;
// routes.home.path is "/" (literal), not string
// routes.dashboard.auth is true (literal), not boolean | undefined
// routes.nonexistent — ERROR (only known keys)
Theme Objects
type ThemeColor = `#${string}` | `rgb(${string})` | `hsl(${string})`;
type Theme = Record<string, ThemeColor>;
const theme = {
primary: "#3b82f6",
secondary: "#10b981",
danger: "#ef4444",
warning: "#f59e0b",
} satisfies Theme;
// theme.primary is "#3b82f6" (literal), not ThemeColor
// theme.invalid = "not-a-color"; // ERROR at the satisfies check
// You get autocomplete for theme.primary, theme.secondary, etc.
Discriminated Union Values
type Action =
| { type: "increment"; amount: number }
| { type: "decrement"; amount: number }
| { type: "reset" };
const increment = { type: "increment", amount: 1 } satisfies Action;
// increment.type is "increment" (literal), not "increment" | "decrement" | "reset"
// increment.amount is number
// TypeScript validates it's a valid Action variant
const bad = { type: "increment" } satisfies Action;
// ERROR: Property 'amount' is missing
satisfies and excess property checking
satisfies applies excess property checking, unlike type annotations on Record types:
type Config = Record<string, number>;
// Type annotation — no excess checking on Records
const a: Config = { x: 1, y: "oops" }; // ERROR only because "oops" isn't number
// No error for extra properties — Record<string, number> accepts any string key
// satisfies — also validates, but preserves specific keys
const b = { x: 1, y: 2 } satisfies Config;
b.z; // ERROR — z doesn't exist on the inferred type { x: number; y: number }This means satisfies gives you narrower types than annotation for objects with string index signatures.
satisfies does NOT change the type — it only checks it. The variable's type is still inferred from the value, not from the satisfies target:
const x = "hello" satisfies string;
// x is still "hello" (literal), not string
// satisfies just confirmed "hello" is a valid string
const arr = [1, 2, 3] satisfies number[];
// arr is still number[] (from inference), NOT readonly
// satisfies doesn't add readonly or change the inferred typeIf you need the type to BE the target type (e.g., for widening), use a type annotation. If you need validation while keeping specific inference, use satisfies.
Production Scenario: Type-Safe Feature Flags
This is where satisfies really earns its keep in real codebases.
type FeatureFlag = {
enabled: boolean;
description: string;
rolloutPercentage?: number;
allowedRoles?: ("admin" | "user" | "beta")[];
};
type FeatureFlags = Record<string, FeatureFlag>;
const flags = {
darkMode: {
enabled: true,
description: "Enable dark mode toggle",
},
newDashboard: {
enabled: false,
description: "Redesigned dashboard",
rolloutPercentage: 25,
allowedRoles: ["admin", "beta"],
},
experimentalSearch: {
enabled: true,
description: "AI-powered search",
allowedRoles: ["admin"],
},
} satisfies FeatureFlags;
// Benefits of satisfies:
// 1. Every flag is validated as a proper FeatureFlag
// 2. flags.darkMode.enabled is 'true' (literal), not boolean
// 3. flags.newDashboard.rolloutPercentage is 25, not number | undefined
// 4. flags.nonexistent — ERROR (autocomplete shows only real flags)
// Compare with annotation:
const flags2: FeatureFlags = { /* same content */ };
// flags2.darkMode.enabled is boolean (widened — lost the true literal)
// flags2.anyString is FeatureFlag (no autocomplete — any string key works)
// Type-safe flag checker
function isEnabled(flag: keyof typeof flags): boolean {
return flags[flag].enabled;
}
isEnabled("darkMode"); // OK
isEnabled("nonexistent"); // ERROR — not a valid flag name
| What developers do | What they should do |
|---|---|
| Using 'as const' when you need type validation as const makes everything readonly and literal but doesn't check if the value matches a target type | Use satisfies — as const doesn't validate against a type, it just freezes inference |
| Using type annotations on config objects and losing literal types Type annotations widen to the declared type. satisfies validates without widening. | Use satisfies to validate the config while preserving exact values |
| Using 'as Type' to cast values that should be validated as bypasses type checking entirely. satisfies actually validates the shape matches. | Use satisfies to validate — it's a check, not a cast |
| Thinking satisfies changes the variable's type satisfies is a compile-time check. The resulting type is what TypeScript infers from the expression. | satisfies only checks — the inferred type comes from the value, not the satisfies target |
Challenge: Config Refactor with satisfies
// Refactor this code to use satisfies so that:
// 1. Every route is validated as a valid Route
// 2. Route paths are preserved as literal types
// 3. keyof typeof routes gives the exact route names
// 4. You get autocomplete on route names
type Route = {
path: string;
method: "GET" | "POST" | "PUT" | "DELETE";
auth: boolean;
};
const routes: Record<string, Route> = {
getUsers: { path: "/api/users", method: "GET", auth: false },
createUser: { path: "/api/users", method: "POST", auth: true },
updateUser: { path: "/api/users/:id", method: "PUT", auth: true },
deleteUser: { path: "/api/users/:id", method: "DELETE", auth: true },
};
// Currently:
// routes.getUsers.path is string (widened)
// routes.anything is Route (no autocomplete)
// typeof routes is Record<string, Route> (no specific keys)
// After refactor, you should get:
// routes.getUsers.path is "/api/users" (literal)
// routes.anything is ERROR (only valid route names)
// typeof routes has specific keys
const routes = {
getUsers: { path: "/api/users", method: "GET", auth: false },
createUser: { path: "/api/users", method: "POST", auth: true },
updateUser: { path: "/api/users/:id", method: "PUT", auth: true },
deleteUser: { path: "/api/users/:id", method: "DELETE", auth: true },
} satisfies Record<string, Route>;
// Now:
routes.getUsers.path; // "/api/users" (literal)
routes.getUsers.method; // "GET" (literal, not "GET" | "POST" | ...)
routes.getUsers.auth; // false (literal, not boolean)
// routes.nonexistent; // ERROR — only 4 valid keys
type RouteNames = keyof typeof routes;
// "getUsers" | "createUser" | "updateUser" | "deleteUser"
// Type-safe route lookup
function getRoute(name: keyof typeof routes) {
return routes[name];
}
getRoute("getUsers"); // OK — autocomplete works
// getRoute("listUsers"); // ERROR — not a valid route name
The only change is replacing : Record<string, Route> with satisfies Record<string, Route>. This preserves every literal type while still validating every route matches the Route shape.
- 1satisfies validates a value matches a type WITHOUT widening — the inferred type is preserved
- 2Type annotations (: Type) widen the variable to the declared type — specific literals and keys are lost
- 3as casts bypass validation entirely — satisfies actually checks the shape
- 4Use satisfies for config objects, theme objects, feature flags — anywhere you need both validation and specific types
- 5satisfies applies excess property checking — even on Record types, only the actual keys are allowed in the resulting type