satisfies — Check Without Widening
Intermediate · 18 min read · ▶ live playground · ✦ checkpoint
satisfies checks that a value fits a type without replacing the value's inferred type. It is the missing middle between "annotate this object and lose useful precision" and "infer this object and never compare it to the shape I meant."
The annotation-vs-inference dilemma
Annotations are promises, and they are still the right tool at boundaries. But annotating a config object can throw away facts the checker already knew.
type RouteConfig = {
method: "GET" | "POST";
path: string;
cache: boolean;
};
function onlyGet(method: "GET"): string {
return method + " only";
}
const annotated: RouteConfig = {
method: "GET",
path: "/users",
cache: true,
};
console.log(onlyGet(annotated.method));→ playground.ts:17:21 - error TS2345: Argument of type '"GET" | "POST"' is not assignable to parameter of type '"GET"'.
Type '"POST"' is not assignable to type '"GET"'.
The object literal says "GET", but the variable is annotated as RouteConfig, so annotated.method is now the full union "GET" | "POST". The annotation checked the shape, then replaced the useful fact that this particular route is a GET route.
Plain inference has the opposite problem. If you write a config object and never compare it to the intended shape, a misspelled property can sit there until some later boundary complains. The checker can infer the object you wrote; it cannot infer the contract you forgot to mention.
satisfies gives you both
Put satisfies after the expression and before the type you want checked:
type RouteConfig = {
method: "GET" | "POST";
path: string;
cache: boolean;
};
type RouteTable = {
[name: string]: RouteConfig;
};
const routes = {
users: { method: "GET", path: "/users", cache: true },
createUser: { method: "POST", path: "/users", cache: false },
} satisfies RouteTable;
function describeRoute(name: string, route: RouteConfig): string {
const cacheLabel = route.cache ? "cached" : "live";
return name + ": " + route.method + " " + route.path + " (" + cacheLabel + ")";
}
function cacheableGet(method: "GET"): string {
return method + " can use the read cache";
}
console.log(describeRoute("users", routes.users));
console.log(cacheableGet(routes.users.method));
console.log(routes.createUser.cache ? "cached" : "live");→ users: GET /users (cached)
→ GET can use the read cache
→ live
routes was checked against RouteTable: every entry needs a valid method, path, and cache. But routes.users.method still has the precise type "GET", so cacheableGet(routes.users.method) works. That is the point: satisfies checks the shape without turning the variable into only the shape.
The typo check is real:
type RouteConfig = {
method: "GET" | "POST";
path: string;
cache: boolean;
};
type RouteTable = {
[name: string]: RouteConfig;
};
const routes = {
users: { method: "GET", path: "/users", cache: true },
broken: { method: "GET", path: "/broken", cach: true },
} satisfies RouteTable;→ playground.ts:13:45 - error TS2561: Object literal may only specify known properties, but 'cach' does not exist in type 'RouteConfig'. Did you mean to write 'cache'?
Config objects are the canonical use
Configuration is where satisfies shines: route tables, feature flags, theme tokens, state-machine descriptions, command maps. These objects are authored in code, read at runtime, and easy to typo. They also benefit from precise per-key inference.
This playground is the pattern you will use most often:
The dashboard entry keeps enough precision for showAudience(features.dashboard.audience) to work because that property is exactly "public". The table is still checked entry by entry. The break-it line misspells audience, and the checker rejects it before anything runs.
The decision table
| Tool | Use it when | What you get |
| --- | --- | --- |
| Annotation: const value: Shape = ... | The variable itself should have the public boundary type | Shape checking, but the variable is viewed as Shape, so literal details can widen away |
| satisfies: const value = ... satisfies Shape | A literal config is the source of truth and you want it checked | Shape checking plus the expression's useful inferred precision |
| Type assertion: value as Shape | You have a specific fact the checker cannot see | A declaration, not validation; it can silence useful errors, so do not use it to "fix" config objects |
That last row is deliberately blunt. satisfies is not a friendlier spelling for a type assertion. If the value is wrong, satisfies still complains. That is exactly why it belongs in config code.
Like the other type-layer tools, satisfies erases. The route example emits ordinary JavaScript:
const routes = {
users: { method: "GET", path: "/users", cache: true },
createUser: { method: "POST", path: "/users", cache: false },
};
function describeRoute(name, route) {
const cacheLabel = route.cache ? "cached" : "live";
return name + ": " + route.method + " " + route.path + " (" + cacheLabel + ")";
}
function cacheableGet(method) {
return method + " can use the read cache";
}
console.log(describeRoute("users", routes.users));
console.log(cacheableGet(routes.users.method));
console.log(routes.createUser.cache ? "cached" : "live");The type aliases are gone, and satisfies RouteTable is gone. What remains is the object you wrote. Next lesson uses all three tools from Section 8 to make the practical choice: enum, literal union, or const object.
Checkpoint
Answer all three to mark this lesson complete