DEV Community

Kai Thorne
Kai Thorne

Posted on

TypeScript's satisfies Operator: When as Lets You Down and Annotations Strip Your Types

Here's a TypeScript scenario that used to drive me insane.

I'd define a route config object — a map of URL paths to their handler metadata. I want TypeScript to validate that every value has the right shape, but I also want autocomplete on the keys and narrow literal types on the values.

type RouteConfig = {
  path: string;
  component: string;
  auth: boolean;
};

// The naive approach with a type annotation:
const routes: Record<string, RouteConfig> = {
  "/dashboard": {
    path: "/dashboard",
    component: "DashboardPage",
    auth: true,
  },
  "/settings": {
    path: "/settings",
    component: "SettingsPage",
    auth: false,
  },
};
Enter fullscreen mode Exit fullscreen mode

This works. TypeScript checks that every value matches RouteConfig. But look what happens when you try to use it:

routes["/dashboard"].auth;
// ^? (property) auth: boolean

routes["/settings"].path;
// ^? (property) path: string
Enter fullscreen mode Exit fullscreen mode

The literal types are gone. auth is boolean, not true | false. path is string, not "/dashboard" | "/settings". If you're building a type-safe router, you just lost the information you needed.

Before TypeScript 4.9, there was no clean way out of this. You had two options, and neither was good.

Option 1: Annotate Nothing (Bare Object)

const routes = {
  "/dashboard": {
    path: "/dashboard",
    component: "DashboardPage",
    auth: true,
  },
  "/settings": {
    path: "/settings",
    component: "SettingsPage",
    auth: false,
  },
};

routes["/dashboard"].auth;
// ^? (property) auth: true — narrowed!
Enter fullscreen mode Exit fullscreen mode

Great, the types are narrow now. But there's no validation. If someone adds a route with a typo in a field name — say componnet: "ProfilePage" — TypeScript won't blink. You only discover the error at runtime.

Option 2: Use as (Type Assertion)

const routes = {
  "/dashboard": {
    path: "/dashboard",
    component: "DashboardPage",
    auth: true,
  },
  "/settings": {
    path: "settings", // oops, missing leading slash
    component: "SettingsPage",
    auth: false,
  },
} as Record<string, RouteConfig>;
Enter fullscreen mode Exit fullscreen mode

The as keyword tells TypeScript "trust me, I know what I'm doing." But that's the problem — as suppresses errors. That missing / in the settings path? Silent. The as assertion validates nothing; it just reassigns the type. And you still lose narrow types.

You had to choose between safety and narrow types. You couldn't have both.

Enter satisfies

The satisfies operator, added in TypeScript 4.9, solves this exact dilemma. It validates that a value's type matches a given type without changing the inferred type of the value.

type RouteConfig = {
  path: string;
  component: string;
  auth: boolean;
};

const routes = {
  "/dashboard": {
    path: "/dashboard",
    component: "DashboardPage",
    auth: true,
  },
  "/settings": {
    path: "/settings",
    component: "SettingsPage",
    auth: false,
  },
} satisfies Record<string, RouteConfig>;
Enter fullscreen mode Exit fullscreen mode

Now look at what TypeScript infers:

routes["/dashboard"].auth;
// ^? (property) auth: true — still narrow!

routes["/dashboard"].path;
// ^? (property) path: "/dashboard" — still the literal type!
Enter fullscreen mode Exit fullscreen mode

The narrow literal types are preserved. TypeScript remembers that auth is exactly true for /dashboard and exactly false for /settings. And path is the exact string literal, not the widened string type.

But validation still works. Try adding a typo:

const routes = {
  "/dashboard": {
    path: "/dashboard",
    componnet: "DashboardPage", // typo!
    auth: true,
  },
} satisfies Record<string, RouteConfig>;
//                  ^^^^^^^^
// Type '{ componnet: string; ... }' is not assignable to type 'RouteConfig'.
// Object literal may only specify known properties, and 'componnet' does not exist in type 'RouteConfig'.
Enter fullscreen mode Exit fullscreen mode

And missing fields are still caught:

const routes = {
  "/dashboard": {
    path: "/dashboard",
    component: "DashboardPage",
  },
} satisfies Record<string, RouteConfig>;
// ❌ Property 'auth' is missing in type '{ path: string; component: string; }' but required in type 'RouteConfig'.
Enter fullscreen mode Exit fullscreen mode

You get the validation of a type annotation and the narrow types of an unannotated value. Both at the same time.

Real-World Pattern: Type-Safe Color Themes

This is where satisfies really shines. Here's a color theme object where you want to ensure every key has the right shape, but also get autocomplete on the color names:

type ThemeColor = {
  light: string;
  dark: string;
  hover?: string;
};

const theme = {
  primary: {
    light: "#6366f1",
    dark: "#818cf8",
    hover: "#4f46e5",
  },
  background: {
    light: "#ffffff",
    dark: "#0f172a",
  },
  error: {
    light: "#ef4444",
    dark: "#f87171",
    // hover is optional — no error if missing
  },
} satisfies Record<string, ThemeColor>;

// Narrow keys are preserved:
type ThemeKeys = keyof typeof theme;
// ^? type ThemeKeys = "primary" | "background" | "error"
Enter fullscreen mode Exit fullscreen mode

Without satisfies, ThemeKeys would be string. With satisfies, you get the exact union of keys you defined. This powers autocomplete in your IDE when you type theme. — you see primary, background, and error, not just "any string."

Real-World Pattern: API Response Enums

Another common spot: mapping API status codes to display labels while keeping strict typing:

type StatusInfo = {
  label: string;
  color: "green" | "yellow" | "red";
  actionable: boolean;
};

const statusMap = {
  active: { label: "Active", color: "green", actionable: true },
  pending: { label: "Pending", color: "yellow", actionable: false },
  suspended: { label: "Suspended", color: "red", actionable: true },
  archived: { label: "Archived", color: "gray", actionable: false },
  // ❌ "'gray' is not assignable to parameter of type 'green' | 'yellow' | 'red'"
} satisfies Record<string, StatusInfo>;
Enter fullscreen mode Exit fullscreen mode

That "gray" value is caught at compile time. Without satisfies, you'd only discover the invalid color when it rendered on screen with a broken CSS class.

Real-World Pattern: Function Dispatch Maps

This is my personal favorite. Dispatch maps are objects where you look up a function by a key and call it. satisfies preserves the return type and parameter types of each function individually:

type EventHandler = (...args: any[]) => void | Promise<void>;

const handlers = {
  user_created: (payload: { id: string; email: string }) => {
    console.log(`New user: ${payload.email}`);
  },
  order_shipped: (payload: { orderId: string; tracking: string }) => {
    console.log(`Order ${payload.orderId} shipped`);
  },
  payment_failed: (payload: { amount: number; reason: string }) => {
    console.error(`Payment failed: ${payload.reason}`);
  },
} satisfies Record<string, EventHandler>;
Enter fullscreen mode Exit fullscreen mode

Now typeof handlers preserves the specific parameter payload types for each handler — TypeScript knows handlers.user_created expects { id: string; email: string } and handlers.payment_failed expects { amount: number; reason: string }. Meanwhile satisfies guarantees each one is a valid function that returns void or Promise<void>. If you accidentally add a non-function value, TypeScript catches it. If you add a handler that returns a number, TypeScript catches that too.

When NOT to Use satisfies

satisfies is not a universal replacement for type annotations or assertions. Here's when to stick with the alternatives:

Use a type annotation when: You want the value to actually become that type. If downstream code needs routes to be typed as Record<string, RouteConfig> (because an API expects that exact type), use a type annotation. satisfies only validates — it doesn't change the type.

Use as when: You know something TypeScript doesn't. If you're parsing JSON.parse() output or interacting with an untyped API, as is your escape hatch. But that's literally the only valid use case.

Use satisfies when: You want validation AND narrow types. This covers most everyday code — configs, maps, enums, themes, event handlers, route definitions. Anywhere you're defining a constrained object and want to preserve its literal types.

Quick Reference

Feature Type Annotation (: Type) Type Assertion (as Type) satisfies
Validates structure ❌ (blindly trusts)
Preserves narrow types ❌ (widens) ❌ (widens)
Catches extra properties
Use for parsing unknown data
Changes the value's type ✅ (widens) ✅ (overrides) ❌ (validates only)

The Bottom Line

satisfies fills a gap that existed in TypeScript for years. Before it, every config object, enum map, and theme definition forced you to choose between type safety and useful types. Now you don't have to compromise.

Next time you reach for a type annotation on an object literal, ask yourself: do I actually want to widen this? Or do I just want to validate it? If the answer is the latter, satisfies is what you're looking for.

Top comments (0)