The satisfies Operator
The satisfies operator, added in TypeScript 4.9, checks that an expression conforms to a given type without changing the type the compiler infers for it. It solves a long-standing tension: a type annotation validates your value but throws away its specific shape, while a type assertion preserves a shape but skips validation. satisfies gives you both — full constraint checking against the target type, plus the narrow, literal-rich type that inference would naturally produce. It is the right tool whenever you want a value to match a contract while still accessing its precise members.
The problem it solves
Consider a palette object that must have string values, but where you also want to use each specific color. An annotation enforces the constraint but erases the specifics.
type Palette = Record<string, string>;
const colors: Palette = {
primary: "#0070f3",
danger: "#e00",
};
colors.primary.toUpperCase(); // ✅ string method
// ❌ Error: Property 'toUpperCase' is fine, but...
// colors.purple — no error! Record<string, string> allows any key
With the annotation, colors is typed as Record<string, string>, so the compiler forgets that only primary and danger exist and lets you read a non-existent colors.purple as string. The literal value information is gone.
How satisfies keeps the narrow type
Replacing the annotation with satisfies validates the same constraint but leaves the inferred type intact.
type Palette = Record<string, string>;
const colors = {
primary: "#0070f3",
danger: "#e00",
} satisfies Palette;
colors.primary.toUpperCase(); // ✅ still a string
// ❌ Error: Property 'purple' does not exist on type '{ primary: string; danger: string; }'.
// colors.purple;
Now colors keeps its exact inferred type { primary: string; danger: string }. You still get the Palette guarantee — every value must be a string — but the compiler also remembers which keys actually exist, catching the typo on purple.
Catching value mismatches
Because satisfies runs a real compatibility check, it flags values that violate the constraint — something an assertion would silently allow.
type RGB = [number, number, number];
type ColorMap = Record<string, RGB | string>;
const theme = {
bg: [255, 255, 255],
fg: "#000",
// ❌ Error: Type 'string' is not assignable to type 'RGB | string'... actually:
accent: [0, 128], // ❌ Error: not a 3-tuple
} satisfies ColorMap;
The error points exactly at accent, where the tuple has the wrong length. An as ColorMap cast would have accepted it without complaint, deferring the failure to runtime.
satisfies vs annotation vs assertion
These three are easy to confuse. The distinction comes down to what gets checked and what type survives.
| Form | Validates value? | Resulting type | Use when |
|---|---|---|---|
const x: T = value | Yes | T (widened) | You want the variable typed as the contract |
const x = value as T | No (override) | T | You know more than the compiler |
const x = value satisfies T | Yes | inferred (narrow) | You want validation and precise members |
const a: string[] = ["a", "b"]; // type: string[]
const b = ["a", "b"] as const; // type: readonly ["a", "b"]
const c = ["a", "b"] satisfies string[]; // type: string[] (here, same as inferred)
The headline: an annotation gives you the broad type, an assertion bypasses checking entirely, and satisfies checks while preserving whatever the value actually is.
Combining satisfies with as const
The two operators compose beautifully. Use as const to lock in literals, then satisfies to assert the locked value still matches a contract.
type RouteConfig = Record<string, { path: string; auth: boolean }>;
const routes = {
home: { path: "/", auth: false },
dashboard: { path: "/dashboard", auth: true },
} as const satisfies RouteConfig;
type RouteKey = keyof typeof routes; // "home" | "dashboard"
routes.dashboard.auth; // type: true (literal, not boolean)
Order matters: write
as const satisfies T. Theas constnarrows first, thensatisfiesverifies the narrowed value fitsT. This pattern gives you immutable, literal-typed config that is also provably correct against its schema.
When to use each tool
Reach for satisfies as the default for typed constant objects — config, route maps, theme tokens, command registries — anywhere you want both a guarantee and rich autocomplete. Use a plain annotation when you genuinely want the variable to be the wider type (for example, a function parameter or a value you will reassign). Reserve as for the rare case where you truly know more than the compiler.
// ✅ config object — satisfies
const settings = { retries: 3, debug: false } satisfies AppSettings;
// ✅ you want the broad type for later reassignment — annotation
let handler: RequestHandler = defaultHandler;
// ⚠️ DOM element the compiler can't infer — assertion
const canvas = document.getElementById("c") as HTMLCanvasElement;
Best Practices
- Default to
satisfiesfor typed constant objects so you get validation and precise inferred types. - Use
as const satisfies T(in that order) for immutable, literal-typed, schema-checked config. - Prefer
satisfiesover a type annotation when you need to access specific keys or literal values. - Prefer
satisfiesoveraswhenever possible — it verifies the value instead of overriding the compiler. - Use a plain annotation when you actually want the broader contract type (parameters, reassignable vars).
- Derive helper types from the inferred value with
keyof typeofand(typeof x)[...]. - Remember
satisfiesis compile-time only and emits no runtime code.