EdgeCases Logo
Feb 2026
TypeScript
Surface
6 min read

TypeScript's satisfies Operator: Validate Without Widening

satisfies validates without widening. Unlike type annotations, it preserves literal types and enables better narrowing downstream.

typescript
satisfies
type-inference
type-safety
literal-types
best-practices

TypeScript 4.9 introduced satisfies—a way to validate that a value matches a type without widening its inferred type. Unlike type annotations (const x: Type = ...) or assertions (as Type), satisfies preserves literal types and enables better type narrowing downstream.

The Problem: Annotations Widen Types

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

// ❌ Annotation widens "GET" to the union
const getUsers: Route = {
  path: "/users",
  method: "GET",
};

getUsers.method; // type: "GET" | "POST" | "PUT" | "DELETE"

// TypeScript can't narrow based on the literal
if (getUsers.method === "GET") {
  // Still thinks method could be any of the union
}

When you annotate const x: Type, TypeScript uses the annotation as the type, discarding the narrower literal information from the value.

The Solution: satisfies Preserves Literals

// ✅ satisfies validates AND preserves the literal
const getUsers = {
  path: "/users",
  method: "GET",
} satisfies Route;

getUsers.method; // type: "GET" (not the union!)

// Perfect narrowing
if (getUsers.method === "GET") {
  // TypeScript knows method is exactly "GET"
}

satisfies checks that the value is assignable to Route, but the resulting type is inferred from the value itself—{ path: "/users"; method: "GET" }.

When satisfies Beats Type Annotations

1. Configuration Objects with Literal Keys

type Config = Record<string, string | number | boolean>;

// ❌ Annotation: loses key information
const config: Config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
  debug: true,
};

config.apiUrl;  // type: string | number | boolean
config.missing; // No error! Any string key is valid

// ✅ satisfies: preserves exact shape
const config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
  debug: true,
} satisfies Config;

config.apiUrl;  // type: string
config.timeout; // type: number
config.missing; // ❌ Error: Property 'missing' does not exist

With satisfies, you get both validation against the constraint AND autocomplete for the exact keys you defined.

2. Discriminated Unions

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

// ❌ Annotation loses the discriminant
const action: Action = { type: "reset" };
action.type; // type: "increment" | "decrement" | "reset"

// ✅ satisfies preserves it
const action = { type: "reset" } satisfies Action;
action.type; // type: "reset"

This matters when passing actions to functions that narrow on the discriminant—the literal type enables exhaustive checking without explicit type guards.

3. Object.keys() and Object.entries()

const routes = {
  home: "/",
  about: "/about",
  contact: "/contact",
} satisfies Record<string, string>;

// Object.keys() returns string[], but we know better
(Object.keys(routes) as (keyof typeof routes)[]).forEach((key) => {
  console.log(routes[key]); // ✅ Safe access
});

While satisfies doesn't fix Object.keys() returning string[], it preserves the object's shape so the cast is sound.

satisfies vs as const

as const makes everything readonly and narrows to literal types, but provides no type safety. satisfies validates against a constraint. Combine them for the best of both:

// as const alone: narrow but no validation
const colors = {
  red: "#ff0000",
  green: "#00ff00",
  blu: "#0000ff",  // Typo! No error
} as const;

// satisfies alone: validates but mutable
const colors = {
  red: "#ff0000",
  green: "#00ff00",
} satisfies Record<"red" | "green" | "blue", string>;
// ❌ Error: missing 'blue'

// ✅ Both: validated AND immutable
const colors = {
  red: "#ff0000",
  green: "#00ff00",
  blue: "#0000ff",
} as const satisfies Record<"red" | "green" | "blue", string>;

colors.red; // type: "#ff0000" (literal, readonly)

The order matters: as const satisfies Type first applies as const, then validates. The resulting type is the as const version.

When NOT to Use satisfies

Function Return Types

// ❌ satisfies on return - checked but not enforced
function getUser() {
  return { name: "Alice", age: 30 } satisfies User;
}
// Return type is inferred, not User. Callers might assume more.

// ✅ Explicit return type - enforced contract
function getUser(): User {
  return { name: "Alice", age: 30 };
}

For function returns, explicit type annotations are still preferred—they enforce the contract at the function boundary, not just validate the implementation.

When You Need the Wider Type

type Handler = (event: Event) => void;

// satisfies preserves the literal, which may be TOO narrow
const onClick = ((e: MouseEvent) => {
  console.log(e.clientX);
}) satisfies Handler;

onClick; // type: (e: MouseEvent) => void
// Can't assign to Handler because MouseEvent is narrower than Event

// Annotation gives you the wider, assignable type
const onClick: Handler = (e) => {
  // e is Event here, need to narrow manually
};

If you need the variable to be assignable to the wider type for polymorphism, use an annotation instead.

satisfies with Generics

satisfies works with generic constraints, enabling validated factory patterns:

type Validator<T> = {
  validate: (input: unknown) => input is T;
  default: T;
};

const stringValidator = {
  validate: (input): input is string => typeof input === "string",
  default: "",
} satisfies Validator<string>;

// Type is preserved: { validate: ..., default: "" }
// But validated against Validator<string>

Real-World Pattern: Route Definitions

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

// Define routes with exact types preserved
const routes = {
  getUsers: {
    path: "/users",
    method: "GET",
    auth: true,
  },
  createUser: {
    path: "/users",
    method: "POST",
    auth: true,
  },
  health: {
    path: "/health",
    method: "GET",
  },
} satisfies Record<string, RouteConfig>;

// Full autocomplete for route names
routes.getUsers.method; // type: "GET"
routes.createUser.method; // type: "POST"

// Type-safe route handler
function handleRoute<K extends keyof typeof routes>(
  name: K,
  handler: (config: typeof routes[K]) => void
) {
  handler(routes[name]);
}

handleRoute("getUsers", (config) => {
  config.method; // type: "GET", not the union!
});

Key Takeaways

  • satisfies validates assignability without widening—literals and exact shapes are preserved
  • Use for config objects, discriminated unions, and anywhere you need both validation and inference
  • Combine with as const for immutable, validated, literal-typed objects
  • Still use explicit annotations for function return types and when you need the wider type
  • satisfies is a compile-time check—zero runtime cost

Advertisement

Related Insights

Explore related edge cases and patterns

TypeScript
Expert
TypeScript Performance: Recursive Types & Build Times
6 min
Performance
Deep
The Web Animation Performance Tier List
6 min

Advertisement