Skip to main content
Advertisement

7.3 The satisfies Operator

There are several ways to specify types when writing TypeScript code: type annotations (: Type), type assertions (as Type), and the satisfies operator added in TypeScript 4.9. While they look similar, they behave in fundamentally different ways. satisfies means: "check that this value satisfies this type, but keep the inferred literal type as-is."


What Is satisfies?

The satisfies operator does two things at once.

  1. Type checking: Verifies at compile time that the value satisfies the specified type.
  2. Literal type preservation: Does not lose the concrete (narrow) inferred type of the value.
type Color = "red" | "green" | "blue";

interface Palette {
primary: Color | [number, number, number];
secondary: Color | [number, number, number];
background: Color | [number, number, number];
}

// Type annotation: type check yes, literal type no (widened)
const palette1: Palette = {
primary: "red",
secondary: [255, 128, 0],
background: "blue",
};
// palette1.primary type: Color | [number, number, number]
// The fact that it is "red" is lost

// satisfies: type check yes, literal type yes (preserved)
const palette2 = {
primary: "red",
secondary: [255, 128, 0],
background: "blue",
} satisfies Palette;

// palette2.primary type: "red"
// palette2.secondary type: [number, number, number]
// palette2.background type: "blue"

// Since the literal type is preserved, array methods are also safe to use
palette2.secondary.map(v => v * 2); // OK — TypeScript knows it's an array
// palette1.secondary.map(v => v * 2); // Error — it's Color | [...], so map might not exist

Differences from as

as (type assertion) forces TypeScript to believe "I assert this is this type, trust me." It bypasses type checking.

type Status = "active" | "inactive" | "pending";

// as assertion: no type check, even wrong values pass
const status1 = "actve" as Status; // Typo, but no error!

// satisfies: type check on, wrong values are errors
// const status2 = "actve" satisfies Status; // Error: "actve" is not in Status

const status2 = "active" satisfies Status; // OK

// Dangerous pattern with as
interface User {
id: number;
name: string;
email: string;
}

// as can assert to a completely different type (double assertion)
const fakeUser = {} as User; // Compiles fine, runtime error risk

// satisfies requires the actual value to satisfy the type
// const fakeUser2 = {} satisfies User; // Error: id, name, email are all missing

// The only legitimate use case for as: when type narrowing is not possible
function processValue(value: unknown): string {
if (typeof value === "string") return value;
return (value as string); // When a runtime check has already been done
}

satisfies vs as in Practice

// Configuration object example
type AppConfig = {
port: number;
host: string;
debug: boolean;
features: string[];
};

// as approach: not type-safe
const config1 = {
port: 3000,
host: "localhost",
debug: true,
features: ["auth", "logging"],
unknownProp: "oops", // No error! as does not catch this
} as AppConfig;

// satisfies approach: includes excess property checking
// const config2 = {
// port: 3000,
// host: "localhost",
// debug: true,
// features: ["auth", "logging"],
// unknownProp: "oops", // Error: unknownProp does not exist in AppConfig
// } satisfies AppConfig;

// Correct usage
const config2 = {
port: 3000,
host: "localhost",
debug: true,
features: ["auth", "logging"],
} satisfies AppConfig;

// Literal type preserved: 3000, not just number
type ConfigPort = typeof config2.port; // 3000 (literal)
type ConfigHost = typeof config2.host; // "localhost" (literal)

Differences from Explicit Type Annotations

A type annotation (: Type) widens the variable's type to the declared type. The inferred literal type is lost.

type Direction = "north" | "south" | "east" | "west";

// Annotation: type is widened to Direction
const dir1: Direction = "north";
type Dir1Type = typeof dir1; // Direction (not a literal)

// satisfies: checks Direction constraint + preserves the literal
const dir2 = "north" satisfies Direction;
type Dir2Type = typeof dir2; // "north" (literal)

// Difference with objects
interface RouteConfig {
path: string;
method: "GET" | "POST" | "PUT" | "DELETE";
handler: string;
}

// Annotation: all properties are widened according to RouteConfig
const route1: RouteConfig = {
path: "/users",
method: "GET",
handler: "getUsers",
};
type Route1Method = typeof route1.method; // "GET" | "POST" | "PUT" | "DELETE"

// satisfies: preserves literal type of each value
const route2 = {
path: "/users",
method: "GET",
handler: "getUsers",
} satisfies RouteConfig;
type Route2Method = typeof route2.method; // "GET" (literal)
type Route2Path = typeof route2.path; // string (literal strings infer as string)

// Usage: exhaustive check in a switch over the route method
function handleRoute(method: typeof route2.method): void {
// method is "GET", so other cases are unreachable
if (method === "GET") {
console.log("handling GET");
}
}

Combining satisfies with as const

Combining as const and satisfies lets you keep the narrowest possible type while also enforcing a type constraint.

type ThemeColor = string;

interface Theme {
colors: Record<string, ThemeColor>;
spacing: Record<string, number | string>;
breakpoints: Record<string, number>;
}

// as const only: no type constraint
const themeConst = {
colors: { primary: "#007bff", secondary: "#6c757d" },
spacing: { sm: 8, md: 16, lg: 24 },
breakpoints: { mobile: 768, tablet: 1024 },
} as const;

// satisfies only: type constraint, partial literal preservation
const themeSatisfies = {
colors: { primary: "#007bff", secondary: "#6c757d" },
spacing: { sm: 8, md: 16, lg: 24 },
breakpoints: { mobile: 768, tablet: 1024 },
} satisfies Theme;

// as const + satisfies combination: maximally narrow type + type constraint
const theme = {
colors: { primary: "#007bff", secondary: "#6c757d" },
spacing: { sm: 8, md: 16, lg: 24 },
breakpoints: { mobile: 768, tablet: 1024 },
} as const satisfies Theme;

// Result
type PrimaryColor = typeof theme.colors.primary; // "#007bff" (literal)
type MobileBreakpoint = typeof theme.breakpoints.mobile; // 768 (literal)

// Errors are caught too
// const badTheme = {
// colors: { primary: 12345 }, // Error: number is not ThemeColor (string)
// } as const satisfies Theme;

// Practical example: icon map
type IconName = "home" | "settings" | "user" | "bell";

const ICONS = {
home: "/icons/home.svg",
settings: "/icons/settings.svg",
user: "/icons/user.svg",
bell: "/icons/bell.svg",
} as const satisfies Record<IconName, string>;

// Keys outside IconName are errors
// const BAD_ICONS = {
// invalid: "/icons/invalid.svg", // Error
// } satisfies Record<IconName, string>;

type IconUrl = typeof ICONS.home; // "/icons/home.svg" (literal)

Practical Example 1: Type-Safe Configuration Objects

// Type-safely defining per-environment configuration
type Environment = "development" | "staging" | "production";

interface DatabaseConfig {
host: string;
port: number;
name: string;
ssl: boolean;
poolSize: number;
}

interface ServerConfig {
host: string;
port: number;
cors: {
origins: string[];
methods: Array<"GET" | "POST" | "PUT" | "DELETE" | "PATCH">;
};
}

interface AppConfig {
env: Environment;
server: ServerConfig;
database: DatabaseConfig;
features: {
auth: boolean;
logging: boolean;
rateLimiting: boolean;
};
}

const devConfig = {
env: "development",
server: {
host: "localhost",
port: 3000,
cors: {
origins: ["http://localhost:3000", "http://localhost:5173"],
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
},
},
database: {
host: "localhost",
port: 5432,
name: "myapp_dev",
ssl: false,
poolSize: 5,
},
features: {
auth: true,
logging: true,
rateLimiting: false,
},
} satisfies AppConfig;

// Literal types are preserved
type DevEnv = typeof devConfig.env; // "development"
type DevPort = typeof devConfig.server.port; // 3000
type DevDbSsl = typeof devConfig.database.ssl; // false

// Config function: return type is inferred precisely
function getConfig(env: Environment) {
const configs = {
development: devConfig,
staging: { ...devConfig, env: "staging" as const },
production: {
...devConfig,
env: "production" as const,
database: { ...devConfig.database, ssl: true, poolSize: 20 },
},
} satisfies Record<Environment, AppConfig>;

return configs[env];
}

const prodConfig = getConfig("production");
type ProdSsl = typeof prodConfig.database.ssl; // boolean (widened by satisfies, but still type-safe)

Practical Example 2: Route Map Definition

// Type-safe route registry
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";

interface RouteDefinition {
method: HttpMethod;
path: string;
description: string;
requiresAuth: boolean;
}

type RouteRegistry = Record<string, RouteDefinition>;

const API_ROUTES = {
listUsers: {
method: "GET",
path: "/api/users",
description: "Get all users",
requiresAuth: true,
},
createUser: {
method: "POST",
path: "/api/users",
description: "Create a new user",
requiresAuth: true,
},
getUser: {
method: "GET",
path: "/api/users/:id",
description: "Get user by ID",
requiresAuth: true,
},
updateUser: {
method: "PUT",
path: "/api/users/:id",
description: "Update user",
requiresAuth: true,
},
deleteUser: {
method: "DELETE",
path: "/api/users/:id",
description: "Delete user",
requiresAuth: true,
},
health: {
method: "GET",
path: "/health",
description: "Health check",
requiresAuth: false,
},
} satisfies RouteRegistry;

// The method of each route is preserved as a literal type
type ListUsersMethod = typeof API_ROUTES.listUsers.method; // "GET"
type CreateUserMethod = typeof API_ROUTES.createUser.method; // "POST"

// An invalid method is a compile error
// const BAD_ROUTES = {
// badRoute: { method: "INVALID", path: "/", ... } // Error
// } satisfies RouteRegistry;

// Using route names as keys
type RouteName = keyof typeof API_ROUTES;
// "listUsers" | "createUser" | "getUser" | "updateUser" | "deleteUser" | "health"

// Filtering only routes that require authentication (accurate type inference thanks to satisfies)
type AuthenticatedRoutes = {
[K in RouteName]: typeof API_ROUTES[K]["requiresAuth"] extends true ? K : never;
}[RouteName];
// "listUsers" | "createUser" | "getUser" | "updateUser" | "deleteUser"

Practical Example 3: Palette Color Types

// Type-safely defining design tokens
type HexColor = `#${string}`;
type RgbColor = `rgb(${number}, ${number}, ${number})`;
type HslColor = `hsl(${number}, ${number}%, ${number}%)`;

type CssColor = HexColor | RgbColor | HslColor;

interface ColorShades {
50: CssColor;
100: CssColor;
200: CssColor;
300: CssColor;
400: CssColor;
500: CssColor;
600: CssColor;
700: CssColor;
800: CssColor;
900: CssColor;
}

interface DesignSystem {
colors: {
primary: ColorShades;
neutral: ColorShades;
error: Partial<ColorShades>;
success: Partial<ColorShades>;
};
}

const designTokens = {
colors: {
primary: {
50: "#eff6ff",
100: "#dbeafe",
200: "#bfdbfe",
300: "#93c5fd",
400: "#60a5fa",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
800: "#1e40af",
900: "#1e3a8a",
},
neutral: {
50: "#f9fafb",
100: "#f3f4f6",
200: "#e5e7eb",
300: "#d1d5db",
400: "#9ca3af",
500: "#6b7280",
600: "#4b5563",
700: "#374151",
800: "#1f2937",
900: "#111827",
},
error: {
500: "#ef4444",
700: "#b91c1c",
},
success: {
500: "#22c55e",
700: "#15803d",
},
},
} satisfies DesignSystem;

// Accessing color values with literal types
type Primary500 = typeof designTokens.colors.primary["500"]; // "#3b82f6"

// Color utility function (precise type inference thanks to literal types)
function getColor<
Category extends keyof typeof designTokens.colors,
Shade extends keyof typeof designTokens.colors[Category]
>(
category: Category,
shade: Shade
): typeof designTokens.colors[Category][Shade] {
return designTokens.colors[category][shade] as any;
}

const primaryBlue = getColor("primary", "500");
// Type: "#3b82f6"

Pro Tips

Limits of satisfies: Inference Depth

satisfies does not widen type inference, but it may behave unexpectedly in deeply nested structures.

// Array contents may not be preserved as literal types
interface Config {
tags: string[];
}

const config = {
tags: ["typescript", "react", "node"],
} satisfies Config;

type Tags = typeof config.tags; // string[] (not a literal array)

// Solved with as const + satisfies
const configConst = {
tags: ["typescript", "react", "node"],
} as const satisfies Config;

type TagsConst = typeof configConst.tags;
// readonly ["typescript", "react", "node"] (tuple literal)

// Limits with function type inference
interface Actions {
onClick: (event: MouseEvent) => void;
}

const actions = {
onClick: (e) => console.log(e.clientX), // e is inferred as MouseEvent
} satisfies Actions;

// Parameter types inside satisfies are inferred contextually — this case works correctly

When to Choose Annotation vs satisfies

// When to choose annotation:
// 1. When you intentionally want to widen the variable's type
let message: string = "hello"; // will reassign to another string later
// message = "world"; // OK

// 2. When the inferred type is too narrow and causes problems
function processStatus(status: string): void {
// various processing inside
}

const STATUS: string = "active"; // needs to be string to pass to processStatus
processStatus(STATUS); // OK

// When to choose satisfies:
// 1. When you need both type safety checking and precise type inference
const httpMethods = ["GET", "POST", "PUT"] satisfies Array<HttpMethod>;
// httpMethods[0] is "GET" (literal) | "POST" | "PUT" — an array but content types are preserved

// 2. When you want to use keys in a config object as types
const featureFlags = {
darkMode: false,
notifications: true,
betaFeatures: false,
} satisfies Record<string, boolean>;

type FeatureFlag = keyof typeof featureFlags;
// "darkMode" | "notifications" | "betaFeatures" (keys preserved thanks to satisfies)
// With annotation it would be: string (keys are lost)

// 3. When defining compile-time constants
const MAX_RETRY = 3 satisfies number;
// typeof MAX_RETRY is 3 (literal), but validated to be a number

Summary

SyntaxType CheckLiteral PreservedWrong ValuePrimary Use
: Type (annotation)YesNo (widened)ErrorVariable type declaration
as Type (assertion)No (forced)NoNo errorType coercion
satisfies TypeYesYesErrorConfig objects, constants
as const satisfies TypeYesYes (maximum)ErrorImmutable constants

In the next chapter, we look at TypeScript 5.x new features such as const type parameters, NoInfer<T>, and the using keyword, and learn about the powerful type inference and resource management capabilities provided by modern TypeScript.

Advertisement