Practical Patterns for the TypeScript 4.9 `satisfies` Operator
TL;DR —
satisfieslets you check a value against a type without widening it / Best uses: config objects, routing tables, key-coerced records, exhaustive enum-like maps / Replaces most legacyas const+ custom helper-generic patterns.
When TypeScript 4.9 shipped in November 2022 with the satisfies operator, I didn’t bother trying it for a few weeks. Type-level features usually solve problems I’ve already worked around. Then I refactored a config module and it suddenly clicked: this isn’t a small ergonomic improvement, it’s a hole I’d been filling with helper generics for years.
Three months in, satisfies is in every config file, every routing table, and every “map of string keys to handlers” object I write. It’s quietly become one of those features whose value compounds. This post is the working set of patterns I keep reaching for — useful especially during the TypeScript migrations I wrote about this week.
The problem it solves
Before 4.9, every TypeScript user had a moment where they wanted to say: “I want to assert this object matches a type — but I want to keep its narrow literal type, not widen it to the type I checked against.”
The classic example:
type Theme = { primary: string; mode: "light" | "dark" };
const theme: Theme = {
primary: "#3b82f6",
mode: "light",
};
theme.mode; // type: "light" | "dark" ← widened
By writing : Theme, you got type-checking, but the compiler forgot that mode was specifically "light". So you couldn’t pass theme.mode somewhere that expected just "light".
The workaround was either as const (no checking) or a helper generic:
const checkTheme = <T extends Theme>(t: T): T => t;
const theme = checkTheme({ primary: "#3b82f6", mode: "light" } as const);
theme.mode; // "light" ✓ — but you wrote a one-off helper
Tolerable for one type. Painful when you have dozens of these in a config-heavy codebase.
What satisfies does
The new keyword runs the type check without applying the type as an annotation:
const theme = {
primary: "#3b82f6",
mode: "light",
} satisfies Theme;
theme.mode; // "light" ← preserved
You get the checking and the narrowing. That’s it. The whole feature.
Where this gets interesting is the patterns it unlocks. Five of them I use weekly.
Pattern 1: Config objects with key-level narrowing
A config object I previously had to choose between checking and narrowing:
type Env = "development" | "staging" | "production";
type EnvConfig = {
apiUrl: string;
logLevel: "debug" | "info" | "warn" | "error";
features: string[];
};
const config = {
development: {
apiUrl: "http://localhost:3000",
logLevel: "debug",
features: ["beta", "experimental"],
},
staging: {
apiUrl: "https://staging.example.com",
logLevel: "info",
features: ["beta"],
},
production: {
apiUrl: "https://api.example.com",
logLevel: "warn",
features: [],
},
} satisfies Record<Env, EnvConfig>;
// Each env's features array keeps its tuple type
config.development.features; // string[]
config.production.features; // string[]
// But we can also narrow logLevel
config.development.logLevel; // "debug" — narrow
The compiler checks that every required Env is present and that each value matches EnvConfig. But you can still narrow on config.development.logLevel because it remembered the literal.
Pattern 2: Routing tables with handler narrowing
If you maintain a hand-rolled HTTP router or an event dispatcher, you want each entry’s handler type to be exact — not a union.
type Method = "GET" | "POST" | "PUT" | "DELETE";
type Handler<TBody = unknown> = (req: { body: TBody }) => Promise<Response>;
type Route =
| { method: "GET"; path: string; handler: Handler<undefined> }
| { method: "POST"; path: string; handler: Handler<{ payload: unknown }> };
const routes = [
{
method: "GET",
path: "/users",
handler: async (req) => new Response("ok"),
},
{
method: "POST",
path: "/users",
handler: async (req) => {
req.body.payload; // ✓ inferred — `req.body` is `{ payload: unknown }`
return new Response("ok");
},
},
] satisfies Route[];
Without satisfies, you’d need either as const (loses validation) or a Routes<T> generic wrapper (verbose). With it, you get full inference inside each handler.
Pattern 3: Discriminated state machines
The pattern that made me a convert. State machines are everywhere in backend code — order lifecycle, subscription state, job status — and the discriminated union plus exhaustive handlers is the cleanest way to model them.
type State =
| { kind: "idle" }
| { kind: "fetching"; startedAt: Date }
| { kind: "success"; result: unknown }
| { kind: "error"; error: Error };
type Handlers = {
[K in State["kind"]]: (state: Extract<State, { kind: K }>) => void;
};
const handlers = {
idle: (s) => console.log("idle"),
fetching: (s) => console.log(`since ${s.startedAt.toISOString()}`),
success: (s) => console.log("got", s.result),
error: (s) => console.error(s.error.message),
} satisfies Handlers;
function transition(state: State) {
handlers[state.kind](state as never); // exhaustive
}
satisfies ensures every state has a handler and each handler gets the narrowly-typed state for its branch. Without it, you’d need to manually type every key.
Pattern 4: API response shapes
For endpoints that return different shapes by query parameter, satisfies keeps the response narrow per call site:
type ResponseMap = {
user: { id: string; email: string };
org: { id: string; name: string; planTier: "free" | "pro" | "enterprise" };
session: { id: string; expiresAt: Date };
};
const responses = {
user: { id: "u_1", email: "a@b.com" },
org: { id: "o_1", name: "Acme", planTier: "pro" },
session: { id: "s_1", expiresAt: new Date() },
} satisfies ResponseMap;
responses.org.planTier; // "pro" — narrow
This shines in test fixtures and example payloads.
Pattern 5: Const enums replacement
Many codebases now avoid enum in TypeScript (Bun, Deno, esbuild can have edge cases with const enums; runtime semantics surprise people). The replacement is a frozen object plus a derived type:
const Roles = {
Admin: "admin",
Editor: "editor",
Viewer: "viewer",
} satisfies Record<string, string>;
type Role = (typeof Roles)[keyof typeof Roles]; // "admin" | "editor" | "viewer"
function check(role: Role) {
if (role === Roles.Admin) {
// ...
}
}
The satisfies Record<string, string> enforces that values are strings without widening them. You get the same effect as a const enum with none of the transpilation footguns.
Where satisfies doesn’t fit
It’s tempting to put satisfies on everything. Don’t.
- Function parameters. Use a regular type annotation;
satisfiesworks on expressions, not parameters. - Return types. Same reason — you want the annotated return type to be the contract, not a derived one.
- When you actually want widening. Sometimes you want
mode: "light"to widen to"light" | "dark"so downstream code can reassign. Plain annotation is correct then. - Library public APIs. Exposing a type like
typeof configas a public API means consumers see the narrow literal type — and your refactors break their code. Use explicit types at module boundaries.
The mental model: satisfies is for values that are also their own types. Configuration, routing tables, fixtures. For the rest, regular annotations are right.
Common Pitfalls
- Using it on parameters or returns. The compiler will accept
function f(x): T satisfies Tin some shapes but it’s almost always wrong. Use annotations. - Confusing it with
as const. They compose.{ a: 1 } as const satisfies SomeShapeis a common pattern when you want deep readonly + narrowing + checking. - Forgetting it doesn’t widen back. If you
satisfiesan object then pass it to a function expectingTheme, the narrow type still satisfies the wider one — that’s the whole point — but TypeScript’s error messages when something doesn’t match can be cryptic. Read them top to bottom. - Reaching for it during a 4.7 migration. If your codebase isn’t on at least 4.9, the operator doesn’t exist. Upgrade first; refactor second. See the TypeScript release notes.
- Trying to use it as runtime validation.
satisfiesis compile-time only. For runtime, use Zod, io-ts, or hand-written guards.
Wrapping Up
satisfies is the rare type-system feature that pays for itself in the first afternoon. If you’re on 4.9 or newer, search your codebase for as const followed by a manual type-check helper — those are the call sites that want satisfies. Next up: Next.js 13 app router from a backend dev’s point of view.
Try it on one config file. The rest writes itself.