background-shape
Practical Patterns for the TypeScript 4.9 `satisfies` Operator
February 9, 2023 · 7 min read · by Muhammad Amal programming

TL;DRsatisfies lets 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 legacy as 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; satisfies works 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 config as 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 T in some shapes but it’s almost always wrong. Use annotations.
  • Confusing it with as const. They compose. { a: 1 } as const satisfies SomeShape is a common pattern when you want deep readonly + narrowing + checking.
  • Forgetting it doesn’t widen back. If you satisfies an object then pass it to a function expecting Theme, 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. satisfies is 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.