Skip to content

The satisfies Operator

advanced12 min read

The Problem satisfies Solves

For years, TypeScript forced you into an annoying tradeoff: either annotate a variable's type (and lose specific inference) or skip the annotation (and lose validation). You couldn't have both. The satisfies operator (TypeScript 4.9+) finally fixes this — it validates that an expression matches a type without widening the inferred type.

Mental Model

Think of satisfies as a quality inspector on the assembly line. A type annotation says "this MUST be exactly this shape" — it replaces what you know about the item. The satisfies operator says "check that this MEETS the requirements, but remember everything specific about it." The item passes inspection but keeps its detailed label.

satisfies vs Type Annotation vs as

type Color = "red" | "green" | "blue";
type Config = Record<string, Color>;

// 1. Type annotation — VALIDATES but WIDENS
const config1: Config = {
  primary: "red",
  secondary: "blue",
};
config1.primary; // type: Color (widened — lost "red")
// config1.tertiary; // No error — Record<string, Color> allows any string key

// 2. No annotation — PRESERVES but DOESN'T VALIDATE
const config2 = {
  primary: "red",
  secondary: "blue",
};
config2.primary; // type: string (inferred — lost Color validation)
// config2.primary could be "purple" — no validation

// 3. satisfies — VALIDATES AND PRESERVES
const config3 = {
  primary: "red",
  secondary: "blue",
} satisfies Config;
config3.primary; // type: "red" (preserved literal type!)
// config3.tertiary; // ERROR — only known keys exist
// config3.primary = "purple"; // ERROR — "purple" is not a Color

The key insight: satisfies validates the shape matches Config while preserving that primary is specifically "red", not the wider Color type.

Where satisfies Shines

Turns out, satisfies is perfect for exactly the patterns you use every day.

Configuration Objects

type Route = {
  path: string;
  component: string;
  auth?: boolean;
};

type Routes = Record<string, Route>;

// With satisfies — validated + specific
const routes = {
  home: { path: "/", component: "HomePage" },
  dashboard: { path: "/dashboard", component: "Dashboard", auth: true },
  settings: { path: "/settings", component: "Settings", auth: true },
} satisfies Routes;

// routes.home.path is "/" (literal), not string
// routes.dashboard.auth is true (literal), not boolean | undefined
// routes.nonexistent — ERROR (only known keys)

Theme Objects

type ThemeColor = `#${string}` | `rgb(${string})` | `hsl(${string})`;
type Theme = Record<string, ThemeColor>;

const theme = {
  primary: "#3b82f6",
  secondary: "#10b981",
  danger: "#ef4444",
  warning: "#f59e0b",
} satisfies Theme;

// theme.primary is "#3b82f6" (literal), not ThemeColor
// theme.invalid = "not-a-color"; // ERROR at the satisfies check
// You get autocomplete for theme.primary, theme.secondary, etc.

Discriminated Union Values

type Action =
  | { type: "increment"; amount: number }
  | { type: "decrement"; amount: number }
  | { type: "reset" };

const increment = { type: "increment", amount: 1 } satisfies Action;
// increment.type is "increment" (literal), not "increment" | "decrement" | "reset"
// increment.amount is number
// TypeScript validates it's a valid Action variant

const bad = { type: "increment" } satisfies Action;
// ERROR: Property 'amount' is missing
satisfies and excess property checking

satisfies applies excess property checking, unlike type annotations on Record types:

type Config = Record<string, number>;

// Type annotation — no excess checking on Records
const a: Config = { x: 1, y: "oops" }; // ERROR only because "oops" isn't number
// No error for extra properties — Record<string, number> accepts any string key

// satisfies — also validates, but preserves specific keys
const b = { x: 1, y: 2 } satisfies Config;
b.z; // ERROR — z doesn't exist on the inferred type { x: number; y: number }

This means satisfies gives you narrower types than annotation for objects with string index signatures.

Common Trap

satisfies does NOT change the type — it only checks it. The variable's type is still inferred from the value, not from the satisfies target:

const x = "hello" satisfies string;
// x is still "hello" (literal), not string
// satisfies just confirmed "hello" is a valid string

const arr = [1, 2, 3] satisfies number[];
// arr is still number[] (from inference), NOT readonly
// satisfies doesn't add readonly or change the inferred type

If you need the type to BE the target type (e.g., for widening), use a type annotation. If you need validation while keeping specific inference, use satisfies.

Production Scenario: Type-Safe Feature Flags

This is where satisfies really earns its keep in real codebases.

type FeatureFlag = {
  enabled: boolean;
  description: string;
  rolloutPercentage?: number;
  allowedRoles?: ("admin" | "user" | "beta")[];
};

type FeatureFlags = Record<string, FeatureFlag>;

const flags = {
  darkMode: {
    enabled: true,
    description: "Enable dark mode toggle",
  },
  newDashboard: {
    enabled: false,
    description: "Redesigned dashboard",
    rolloutPercentage: 25,
    allowedRoles: ["admin", "beta"],
  },
  experimentalSearch: {
    enabled: true,
    description: "AI-powered search",
    allowedRoles: ["admin"],
  },
} satisfies FeatureFlags;

// Benefits of satisfies:
// 1. Every flag is validated as a proper FeatureFlag
// 2. flags.darkMode.enabled is 'true' (literal), not boolean
// 3. flags.newDashboard.rolloutPercentage is 25, not number | undefined
// 4. flags.nonexistent — ERROR (autocomplete shows only real flags)

// Compare with annotation:
const flags2: FeatureFlags = { /* same content */ };
// flags2.darkMode.enabled is boolean (widened — lost the true literal)
// flags2.anyString is FeatureFlag (no autocomplete — any string key works)

// Type-safe flag checker
function isEnabled(flag: keyof typeof flags): boolean {
  return flags[flag].enabled;
}

isEnabled("darkMode");     // OK
isEnabled("nonexistent");  // ERROR — not a valid flag name
Execution Trace
Infer
TypeScript infers the exact object type from the value
{ darkMode: { enabled: true, description: '...' }, ... }
Check
satisfies FeatureFlags validates the shape
Each value must match FeatureFlag. Missing required fields error here.
Preserve
Inferred type is kept, NOT widened to FeatureFlags
flags.darkMode.enabled is true, not boolean
Use
keyof typeof flags gives exact flag names
'darkMode' | 'newDashboard' | 'experimentalSearch' — not string
What developers doWhat they should do
Using 'as const' when you need type validation
as const makes everything readonly and literal but doesn't check if the value matches a target type
Use satisfies — as const doesn't validate against a type, it just freezes inference
Using type annotations on config objects and losing literal types
Type annotations widen to the declared type. satisfies validates without widening.
Use satisfies to validate the config while preserving exact values
Using 'as Type' to cast values that should be validated
as bypasses type checking entirely. satisfies actually validates the shape matches.
Use satisfies to validate — it's a check, not a cast
Thinking satisfies changes the variable's type
satisfies is a compile-time check. The resulting type is what TypeScript infers from the expression.
satisfies only checks — the inferred type comes from the value, not the satisfies target
Quiz
What's the key difference between const x: Type = value and const x = value satisfies Type?
Quiz
Given: const x = { a: 1, b: 'hello' } satisfies Record<string, string | number> — what is typeof x.a?
Quiz
When should you use satisfies instead of a type annotation?

Challenge: Config Refactor with satisfies

// Refactor this code to use satisfies so that:
// 1. Every route is validated as a valid Route
// 2. Route paths are preserved as literal types
// 3. keyof typeof routes gives the exact route names
// 4. You get autocomplete on route names

type Route = {
  path: string;
  method: "GET" | "POST" | "PUT" | "DELETE";
  auth: boolean;
};

const routes: Record<string, Route> = {
  getUsers: { path: "/api/users", method: "GET", auth: false },
  createUser: { path: "/api/users", method: "POST", auth: true },
  updateUser: { path: "/api/users/:id", method: "PUT", auth: true },
  deleteUser: { path: "/api/users/:id", method: "DELETE", auth: true },
};

// Currently:
// routes.getUsers.path is string (widened)
// routes.anything is Route (no autocomplete)
// typeof routes is Record<string, Route> (no specific keys)

// After refactor, you should get:
// routes.getUsers.path is "/api/users" (literal)
// routes.anything is ERROR (only valid route names)
// typeof routes has specific keys
const routes = {
  getUsers: { path: "/api/users", method: "GET", auth: false },
  createUser: { path: "/api/users", method: "POST", auth: true },
  updateUser: { path: "/api/users/:id", method: "PUT", auth: true },
  deleteUser: { path: "/api/users/:id", method: "DELETE", auth: true },
} satisfies Record<string, Route>;

// Now:
routes.getUsers.path;     // "/api/users" (literal)
routes.getUsers.method;   // "GET" (literal, not "GET" | "POST" | ...)
routes.getUsers.auth;     // false (literal, not boolean)

// routes.nonexistent;    // ERROR — only 4 valid keys

type RouteNames = keyof typeof routes;
// "getUsers" | "createUser" | "updateUser" | "deleteUser"

// Type-safe route lookup
function getRoute(name: keyof typeof routes) {
  return routes[name];
}

getRoute("getUsers");      // OK — autocomplete works
// getRoute("listUsers"); // ERROR — not a valid route name

The only change is replacing : Record<string, Route> with satisfies Record<string, Route>. This preserves every literal type while still validating every route matches the Route shape.

Key Rules
  1. 1satisfies validates a value matches a type WITHOUT widening — the inferred type is preserved
  2. 2Type annotations (: Type) widen the variable to the declared type — specific literals and keys are lost
  3. 3as casts bypass validation entirely — satisfies actually checks the shape
  4. 4Use satisfies for config objects, theme objects, feature flags — anywhere you need both validation and specific types
  5. 5satisfies applies excess property checking — even on Record types, only the actual keys are allowed in the resulting type