TypeScript 4.9 added satisfies to solve a specific annoyance. You have a configuration object, say a route table or a colour palette, and you want two things at once: the compiler should check every entry against a shared shape, and it should still remember exactly which keys and values you wrote. Before satisfies, you had to pick one. Interviewers ask about it because the answer shows whether you understand the difference between checking a value and declaring its type.
The trade-off an annotation forces
Start with a route table annotated the usual way:
type Route = { path: string; public: boolean };
const annotated: Record<string, Route> = {
home: { path: "/", public: true },
account: { path: "/account", public: false },
};
annotated.acount.path; // compilesThe annotation does its job on the right-hand side: every entry is checked against Route. But once checked, the variable’s type is exactly what you declared, Record<string, Route>. That type says any string key is valid, so the typo acount compiles. At runtime annotated.acount is undefined and .path throws a TypeError. You also lose autocomplete for route names, and keyof typeof annotated is just string.
Dropping the annotation fixes the keys but loses the check. A missing public or a misspelled field would go unnoticed until something reads it.
What satisfies does instead
Move the type to the end of the expression:
const routes = {
home: { path: "/", public: true },
account: { path: "/account", public: false },
} satisfies Record<string, Route>;
type RouteName = keyof typeof routes; // "home" | "account"
routes.acount.path;sat1.ts(15,8): error TS2551: Property 'acount' does not exist on type '{ home: { path: string; public: true; }; account: { path: string; public: false; }; }'. Did you mean 'account'?satisfies asks the compiler to check that the expression is assignable to Record<string, Route> and then to forget about that type. The variable keeps the type TypeScript inferred from the literal, which has exactly two keys. So the typo is caught, RouteName is a precise union you can use elsewhere, and the editor autocompletes routes.home.
The check side is just as strict as an annotation. Wrong values and misspelled properties are both reported, at the line where they appear:
const broken = {
home: { path: "/", public: "yes" },
login: { path: "/login", publc: true },
} satisfies Record<string, Route>;sat1.ts(18,22): error TS2322: Type 'string' is not assignable to type 'boolean'.
sat1.ts(19,28): error TS2561: Object literal may only specify known properties, but 'publc' does not exist in type 'Route'. Did you mean to write 'public'?The second error is the excess property check, which only applies to object literals checked against a target type. satisfies gives you that check without the widening that comes with an annotation.
Unions keep their narrow member
The other classic use is a value whose type is a union. Here a palette entry can be a hex string or an RGB tuple:
type Color = string | [number, number, number];
const palette = {
brand: "#0b5fff",
accent: [255, 99, 71],
} satisfies Record<string, Color>;
palette.brand.toUpperCase(); // ok: brand is string
palette.accent.map((c) => c / 255); // ok: accent is a tuple
const paletteAnnotated: Record<string, Color> = { brand: "#0b5fff", accent: [255, 99, 71] };
paletteAnnotated.brand.toUpperCase();sat1.ts(31,24): error TS2339: Property 'toUpperCase' does not exist on type 'Color'.
Property 'toUpperCase' does not exist on type '[number, number, number]'.With the annotation, every entry is Color, so before calling a string method you’d need a typeof check, even though you wrote a string on the line above. With satisfies, each entry keeps the specific member it matched. The TypeScript 4.9 release notes introduce the operator with a palette much like this one, and it’s a good example to reproduce in an interview.
Note the tuple. Without any contextual type, [255, 99, 71] would be inferred as number[]. The satisfies target gives it a contextual type, so it’s inferred as a three-element tuple instead. The type you’re checking against still influences inference; it just doesn’t replace the result.
Satisfies is not a cast
People sometimes treat satisfies and as as interchangeable. They’re nearly opposites:
const a = {} as Route; // compiles, a is a Route with nothing in it
const b = {} satisfies Route;sat2.ts(3,14): error TS2739: Type '{}' is missing the following properties from type 'Route': path, publicas tells the compiler to trust you, and it accepts an empty object as a Route. satisfies asks the compiler to verify, and it refuses. If you’re choosing between them to make an error go away, satisfies is the one that leaves the safety in place.
The same thing makes satisfies useless for external data:
const json: unknown = JSON.parse('{"path":1}');
const c = json satisfies Route;sat2.ts(5,16): error TS1360: Type 'unknown' does not satisfy the expected type 'Route'.That’s the correct answer: the compiler can’t know what the JSON contains. satisfies checks expressions the compiler can see, such as literals in your source. It emits no runtime code. Data from a request, a file or localStorage needs a real validator, whether a hand-written type guard or a schema library.
A literal-type surprise
Because satisfies keeps the inferred type, it also keeps literal types in places an annotation would widen:
const d = { path: "/", public: true } satisfies Route;
let e = d;
e.public = false;sat2.ts(8,1): error TS2322: Type 'false' is not assignable to type 'true'.The boolean was contextually typed by Route["public"], so TypeScript kept the literal true, and the property is now stuck at that type. For configuration you won’t change, that’s a feature. For an object you plan to mutate, an annotation is the right tool, because you want the variable to have the general type.
The flip side is the common combination with as const. { port: 3000, host: "localhost" } as const satisfies Record<string, string | number> gives you readonly literal values and still checks every entry. The order matters: as const applies to the literal, and satisfies checks the result.
Choosing between them
A workable rule: use satisfies for values whose exact contents are the point, such as route tables, lookup maps, theme tokens and test fixtures, and use an annotation when you want the variable to stand for the general type. Exported objects are worth a second thought. With satisfies, consumers of your module see the inferred type, so adding a route changes the exported type. Inside one application that’s usually what you want. In a published library, an annotation, or an explicit exported type, gives you a stable contract that doesn’t shift every time the data changes. Function return values follow the same logic: annotate the return type when it’s an API promise, and let inference plus satisfies handle internal constants.
Interview practice
You’re reviewing this code: export const FEATURES: Record<string, { enabled: boolean; owner: string }> = { search: {...}, checkout: {...} }, and elsewhere isOn(name: string) { return FEATURES[name].enabled }. Someone called isOn("serch") and it crashed in production. How would satisfies help, and what else would you change?
Answer
The annotation widens FEATURES to a record with arbitrary string keys, so FEATURES["serch"] is typed as a valid entry even though it’s undefined at runtime. I’d change the declaration to export const FEATURES = { ... } satisfies Record<string, { enabled: boolean; owner: string }>. Every entry is still checked for enabled and owner, including misspelled field names, but the object keeps its real keys. Then I’d derive type FeatureName = keyof typeof FEATURES and change the function to isOn(name: FeatureName), so isOn("serch") is a compile error at the call site, with autocomplete for the valid names.
If some names come from outside, such as a query string, I’d add a guard isFeatureName(s: string): s is FeatureName using Object.hasOwn(FEATURES, s) and call it before isOn, because satisfies only checks code the compiler can see. Turning on noUncheckedIndexedAccess would also have flagged the original lookup as possibly undefined.