TypeScript satisfies: narrow literals without widening the type
A type annotation widens object literals. satisfies checks the shape and keeps narrow inference, so autocomplete and narrowing still work.
You annotate a config object "for safety," and suddenly palette.green.toUpperCase() blows up in the type checker. The value is still a string at runtime. TypeScript just forgot.
That is the trap: a type annotation replaces the inferred type and widens literals. satisfies checks the shape and keeps narrow inference so autocomplete and narrowing still work. Official write-up: TypeScript 4.9 — The satisfies operator.
Problem: annotations eat your literals
Say you map route keys to loaders in a React or Next.js app (example):
type Loader = () => Promise<{ title: string }>;
const loaders: Record<RouteId, Loader> = {
home: async () => ({ title: "Home" }),
blog: async () => ({ title: "Blog" }),
about: async () => ({ title: "About" }),
};
// Fine... until you want the key list as a tuple of literals:
const routeIds = Object.keys(loaders);
// string[] - not ("home" | "blog" | "about")[]Or the classic palette case from the docs. Annotate with Record and a typo like bleu is caught, but palette.green becomes string | RGB, so string methods disappear.
Solution: satisfies without widening
Use satisfies when you care about the value's precise type and still want a shape check:
type RGB = [red: number, green: number, blue: number];
const palette = {
red: [255, 0, 0],
green: "#00ff00",
blue: [0, 0, 255],
} satisfies Record<Colors, string | RGB>;
// Still a string - methods work
const green = palette.green.toUpperCase();
// Still a tuple - index access stays precise
const r = palette.red[0];What changed:
- Missing or misspelled keys fail the check (same as a
Recordannotation). - Property types stay specific (
"#00ff00"stays string, notstring | RGB). - You get the guardrail without throwing away inference.
For route maps, the same idea:
const loaders = {
home: async () => ({ title: "Home" }),
blog: async () => ({ title: "Blog" }),
about: async () => ({ title: "About" }),
} satisfies Record<RouteId, () => Promise<{ title: string }>>;
type KnownRoute = keyof typeof loaders; // "home" | "blog" | "about"Optional combo when you also want readonly literals: as const satisfies SomeType (order matters: as const first, then satisfies). That pattern shows up a lot in config and i18n maps; see also Total TypeScript on satisfies.
Pitfalls and limits
- Prefer
satisfiesfor object literals you will read with property-specific logic (configs, route tables, feature flags). - Prefer a plain annotation when you want the widened type (e.g. a mutable
Recordyou assign into later). satisfiesis a compile-time check only. It does not exist at runtime and does not validate API payloads.- Needs TypeScript 4.9+. Older projects: upgrade
typescript(and your editor TS version) first. - It does not deep-freeze or copy. Mutability is unchanged unless you add
as const/readonly. - Excess property checks still follow normal object-literal rules; nested objects may need their own
satisfies. - If the right-hand constraint is too loose (
unknown, wide unions), you get little benefit. Tighten the constraint to the shape you actually mean.
When a mid-level frontend says "I typed this object and lost my autocomplete," the usual culprit is annotation widening.
satisfies keeps both the check and the narrow type. Use it on the next config map instead of another annotation "for safety."