Introduction
The satisfies operator, introduced in TypeScript 4.9, solves a long-standing problem in the type system: how to validate that an expression matches a type without widening it. Before satisfies, developers faced an impossible choice between type annotations (which validate structure but lose literal type information) and as const (which preserves literals but prevents mutation and doesn't validate against a schema). satisfies gives you the best of both worldsβcompile-time type checking at the definition site while preserving the exact, narrow type of the expression.
This operator matters because literal types carry significant information. When you write const port = 3000, TypeScript infers the type as number. But with const port = 3000 as const, the type becomes the literal 3000. This distinction is crucial for configuration objects, route tables, theme definitions, and API schemas where the exact values determine behavior. The satisfies operator lets you validate these structures against a known type while keeping the literal information intact, enabling both safety and precision.
Since its introduction, satisfies has become a standard tool in TypeScript development. Frameworks like Next.js, Astro, and Vite use it extensively for configuration validation. Libraries like Zod and tRPC integrate with it for schema definitions. Understanding satisfies is essential for writing modern TypeScript that is both safe and precise.
Core Concepts and Architecture
The Problem with Type Annotations
Type annotations validate that an expression matches a type, but they widen the expression's type in the process. When you annotate a variable with a type, TypeScript uses that annotation as the variable's type, discarding any narrower information that the expression itself provides.
// Type annotation: validates structure but widens types
type Theme = {
colors: {
primary: string;
secondary: string;
};
spacing: {
sm: string;
md: string;
};
};
const theme: Theme = {
colors: {
primary: '#0066cc',
secondary: '#6c757d',
},
spacing: {
sm: '8px',
md: '16px',
},
};
// theme.colors.primary is typed as `string`, not `'#0066cc'`
// This means you can't use it where a literal type is expected
type PrimaryColor = typeof theme.colors.primary; // stringThe widening problem becomes apparent when you try to use the annotated value in contexts that require literal types. If a function expects 'light' | 'dark' but your annotation says string, the call fails even though the actual value is 'light'.
The Problem with as const
The as const assertion preserves literal types by making everything deeply readonly and literal. This solves the widening problem but introduces new issues: you can't mutate the value, and there's no validation against a known type.
// as const: preserves literals but no type validation
const config = {
apiUrl: 'https://api.example.com',
port: 3000,
debug: false,
} as const;
// config.apiUrl is typed as `'https://api.example.com'` (literal!)
// But: no validation that config matches any expected type
// And: config is readonly, so config.port = 4000 is an errorThe lack of validation means typos and missing properties go undetected at the definition site. If you misspell a property name or omit a required field, the error only surfaces when you try to use the value, making debugging harder.
How satisfies Works
The satisfies operator validates that an expression matches a type while preserving the expression's inferred type. It performs the same structural checks as a type annotation but doesn't change the type of the expression.
type Config = {
apiUrl: string;
port: number;
debug: boolean;
};
const config = {
apiUrl: 'https://api.example.com',
port: 3000,
debug: false,
} satisfies Config;
// config.apiUrl is typed as `'https://api.example.com'` (literal preserved!)
// config.port is typed as `3000` (literal preserved!)
// Missing or misspelled properties cause compile errors at definition
// config is mutable: config.port = 4000 is allowedThe key insight is that satisfies performs a subtype check (expr satisfies T requires typeof expr to be a subtype of T) without changing the type of expr. This means the expression retains its inferred type, including all literal information, while the type parameter provides validation.
Implementation Guide
Configuration Objects
Configuration objects are the most common use case for satisfies. They benefit from both validation (ensuring all required fields are present) and literal preservation (enabling type-safe lookups).
type EnvConfig = {
apiUrl: string;
port: number;
logLevel: 'debug' | 'info' | 'warn' | 'error';
features: string[];
};
const config = {
apiUrl: 'https://api.example.com',
port: 3000,
logLevel: 'info',
features: ['auth', 'logging', 'metrics'],
} satisfies EnvConfig;
// Literal types preserved
config.logLevel; // type: 'info' (not just string)
config.port; // type: 3000 (not just number)
// Validation catches errors at definition
const badConfig = {
apiUrl: 'https://api.example.com',
// port missing! Error: Property 'port' is missing
logLevel: 'info',
features: [],
} satisfies EnvConfig;Route Definitions
Route tables benefit from satisfies because the exact path strings and component names matter for type-safe navigation:
type Route = {
path: string;
component: string;
meta?: {
title: string;
requiresAuth?: boolean;
roles?: string[];
};
};
const routes = {
home: {
path: '/',
component: 'HomePage',
meta: { title: 'Home' },
},
about: {
path: '/about',
component: 'AboutPage',
},
dashboard: {
path: '/dashboard',
component: 'DashboardPage',
meta: { title: 'Dashboard', requiresAuth: true, roles: ['admin'] },
},
profile: {
path: '/profile/:userId',
component: 'ProfilePage',
meta: { title: 'Profile', requiresAuth: true },
},
} satisfies Record<string, Route>;
// Type-safe access with literal types
routes.home.path; // type: '/' (literal!)
routes.home.meta?.title; // type: 'Home' (literal!)
routes.dashboard.meta?.roles; // type: string[] | undefined
// Validation catches structural errors
const badRoutes = {
home: {
path: 123, // Error: Type 'number' is not assignable to type 'string'
component: 'HomePage',
},
} satisfies Record<string, Route>;Theme Definitions
Design system themes use satisfies to validate color values and spacing while preserving literal information for documentation generation:
type ColorScale = {
50: string;
100: string;
200: string;
300: string;
400: string;
500: string;
600: string;
700: string;
800: string;
900: string;
};
type Theme = {
colors: {
primary: ColorScale;
neutral: ColorScale;
success: string;
error: string;
warning: string;
};
spacing: {
xs: string;
sm: string;
md: string;
lg: string;
xl: string;
};
fonts: {
body: string;
heading: string;
mono: string;
};
};
const theme = {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
neutral: {
50: '#fafafa',
100: '#f5f5f5',
200: '#e5e5e5',
300: '#d4d4d4',
400: '#a3a3a3',
500: '#737373',
600: '#525252',
700: '#404040',
800: '#262626',
900: '#171717',
},
success: '#22c55e',
error: '#ef4444',
warning: '#f59e0b',
},
spacing: {
xs: '4px',
sm: '8px',
md: '16px',
lg: '24px',
xl: '32px',
},
fonts: {
body: 'Inter, system-ui, sans-serif',
heading: 'Inter, system-ui, sans-serif',
mono: 'JetBrains Mono, monospace',
},
} satisfies Theme;
// Literal types enable precise lookups
theme.colors.primary[500]; // type: '#3b82f6' (literal!)
theme.spacing.md; // type: '16px' (literal!)API Schema Definitions
API endpoint definitions benefit from satisfies because the method and path literals are important for type-safe request construction:
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
type Endpoint = {
method: HttpMethod;
path: string;
requestBody?: unknown;
response: unknown;
};
const api = {
getUsers: {
method: 'GET',
path: '/api/users',
response: {} as User[],
},
getUser: {
method: 'GET',
path: '/api/users/:id',
response: {} as User,
},
createUser: {
method: 'POST',
path: '/api/users',
requestBody: {} as Omit<User, 'id'>,
response: {} as User,
},
updateUser: {
method: 'PUT',
path: '/api/users/:id',
requestBody: {} as Partial<User>,
response: {} as User,
},
deleteUser: {
method: 'DELETE',
path: '/api/users/:id',
response: {} as { success: boolean },
},
} satisfies Record<string, Endpoint>;
// Literal method types preserved
api.getUsers.method; // type: 'GET' (literal!)
api.createUser.method; // type: 'POST' (literal!)Database Schema Definitions
type ColumnType = 'string' | 'number' | 'boolean' | 'date' | 'json';
type Column = {
type: ColumnType;
nullable?: boolean;
default?: unknown;
unique?: boolean;
primary?: boolean;
};
const userSchema = {
id: { type: 'number', primary: true, unique: true },
name: { type: 'string' },
email: { type: 'string', unique: true },
isActive: { type: 'boolean', default: true },
createdAt: { type: 'date', default: 'now()' },
metadata: { type: 'json', nullable: true },
} satisfies Record<string, Column>;
// Literal types preserved for default values
userSchema.isActive.default; // type: true (literal!)
userSchema.id.primary; // type: true (literal!)Event Maps
Type-safe event systems use satisfies to validate event handler signatures while preserving the exact event types:
type EventHandler<T> = (event: T) => void;
const handlers = {
click: (event) => {
console.log(event.clientX, event.clientY); // MouseEvent
},
keydown: (event) => {
console.log(event.key); // KeyboardEvent
},
scroll: (event) => {
console.log(event.scrollY); // Event
},
resize: (event) => {
console.log(window.innerWidth); // UIEvent
},
} satisfies Record<string, EventHandler<any>>;
// Each handler has the correct event type inferred
handlers.click; // (event: MouseEvent) => void
handlers.keydown; // (event: KeyboardEvent) => voidAdvanced Patterns
Discriminated Union Builders
type Action =
| { type: 'increment'; amount: number }
| { type: 'decrement'; amount: number }
| { type: 'reset' }
| { type: 'set'; value: number };
const actionCreators = {
increment: (amount: number) => ({ type: 'increment' as const, amount }),
decrement: (amount: number) => ({ type: 'decrement' as const, amount }),
reset: () => ({ type: 'reset' as const }),
set: (value: number) => ({ type: 'set' as const, value }),
} satisfies Record<string, (...args: any[]) => Action>;
// Type-safe action creation
const action = actionCreators.increment(10);
// type: { type: 'increment'; amount: number }Combining satisfies with as const
For maximum type safety, combine satisfies with as const:
type MenuConfig = {
label: string;
icon?: string;
children?: MenuConfig[];
};
const menu = {
label: 'Main Menu',
children: [
{
label: 'File',
icon: 'file',
children: [
{ label: 'New', icon: 'plus' },
{ label: 'Open', icon: 'folder' },
{ label: 'Save', icon: 'save' },
],
},
{
label: 'Edit',
icon: 'edit',
children: [
{ label: 'Undo', icon: 'undo' },
{ label: 'Redo', icon: 'redo' },
],
},
],
} as const satisfies MenuConfig;
// Deeply literal types
menu.children[0].children[0].label; // type: 'New' (literal!)
menu.children[1].icon; // type: 'edit' (literal!)Type-Safe State Machines
type State = 'idle' | 'loading' | 'success' | 'error';
type Event = 'FETCH' | 'RESOLVE' | 'REJECT' | 'RESET';
type Transition = {
from: State;
to: State;
event: Event;
};
const transitions = {
fetch: { from: 'idle', to: 'loading', event: 'FETCH' },
resolve: { from: 'loading', to: 'success', event: 'RESOLVE' },
reject: { from: 'loading', to: 'error', event: 'REJECT' },
retry: { from: 'error', to: 'loading', event: 'FETCH' },
reset: { from: 'success', to: 'idle', event: 'RESET' },
resetFromError: { from: 'error', to: 'idle', event: 'RESET' },
} satisfies Record<string, Transition>;
// Literal state types preserved
transitions.fetch.from; // type: 'idle' (literal!)
transitions.fetch.to; // type: 'loading' (literal!)Nested Configuration with Type Inference
type DatabaseConfig = {
host: string;
port: number;
database: string;
pool: {
min: number;
max: number;
};
};
type AppConfig = {
server: {
host: string;
port: number;
cors: {
origin: string | string[];
credentials: boolean;
};
};
database: DatabaseConfig;
cache: {
ttl: number;
maxSize: number;
};
};
const appConfig = {
server: {
host: '0.0.0.0',
port: 3000,
cors: {
origin: ['http://localhost:5173', 'https://example.com'],
credentials: true,
},
},
database: {
host: 'localhost',
port: 5432,
database: 'myapp',
pool: {
min: 2,
max: 10,
},
},
cache: {
ttl: 3600,
maxSize: 1000,
},
} satisfies AppConfig;
// All literal types preserved
appConfig.server.port; // type: 3000 (literal!)
appConfig.database.port; // type: 5432 (literal!)
appConfig.server.cors.credentials; // type: true (literal!)satisfies vs Type Annotation vs as const
| Feature | Type Annotation | as const | satisfies |
|---|---|---|---|
| Type checking | Yes | No | Yes |
| Preserves literals | No | Yes | Yes |
| Allows mutation | Yes | No | Yes |
| Excess property check | No | Yes | Yes |
| Widens types | Yes | No | No |
| Validates structure | Yes | No | Yes |
| Deep readonly | No | Yes | No |
Best Practices
-
Use
satisfiesfor config objects β Configuration objects benefit most fromsatisfiesbecause they need both validation (all required fields present) and literal preservation (exact values available for type-safe lookups). -
Use type annotations for public APIs β When defining interfaces that other modules or teams will consume, type annotations provide clearer documentation of the expected shape.
satisfiesis better for internal definitions where literal types matter. -
Use
as constfor readonly data β When mutation should be prevented entirely (enum-like constants, lookup tables),as constis the right choice. Combine withsatisfiesfor both readonly and validated. -
Combine
satisfieswithas constβ For maximum type safety on immutable configuration, useas const satisfies T. This validates structure, prevents mutation, and preserves literal types. -
Prefer
satisfiesover type assertions β Theaskeyword bypasses type checking.satisfiesprovides the same structural validation without the safety risks of assertions. -
Use
satisfiesfor discriminated union builders β Action creators, event factories, and state machine definitions benefit fromsatisfiesbecause the discriminant literals carry important type information. -
Apply
satisfiesto lookup tables β When you have a record mapping keys to typed values,satisfiesensures every entry conforms to the expected type while preserving the specific value types. -
Use
satisfiesin test fixtures β Test data that needs to match a schema but also needs literal types for assertions benefits fromsatisfies.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Using on function types | Complex inference, type errors | Use type annotation for function declarations |
| Expecting runtime validation | No runtime effect, bugs pass through | Use with Zod or io-ts for runtime checks |
| Overusing on simple types | Unnecessary complexity | Only use when literal types provide value |
| Not preserving readonly | Mutations allowed on sensitive data | Combine with as const |
| Using with union types | Unexpected narrowing behavior | Test with representative union members |
Confusing with as keyword | as bypasses checking, satisfies validates | Remember: satisfies is a check, as is an assertion |
Performance Considerations
The satisfies operator has no runtime costβit's purely a compile-time check that TypeScript erases during compilation. The generated JavaScript is identical whether you use satisfies, a type annotation, or neither. However, complex type parameters can increase compilation time, particularly when combined with conditional types or large union types.
For most projects, the compilation overhead of satisfies is negligible. Profile your build times if you notice slowdowns, and consider simplifying the type parameter if it involves deeply nested conditional types or recursive structures.
Testing Strategies
Testing code that uses satisfies focuses on verifying that the validation works correctly:
// Test that satisfies catches missing properties
const validConfig = {
apiUrl: 'https://api.example.com',
port: 3000,
} satisfies { apiUrl: string; port: number };
// This should cause a compile error:
// const invalidConfig = {
// apiUrl: 'https://api.example.com',
// // port missing
// } satisfies { apiUrl: string; port: number };
// Test that literal types are preserved
type AssertExact<T, U> = [T] extends [U] ? [U] extends [T] ? true : false : false;
const testConfig = { port: 3000 } satisfies { port: number };
type PortType = typeof testConfig.port;
type IsLiteral = AssertExact<PortType, 3000>; // trueWrite tests that verify both the validation (missing properties cause errors) and the literal preservation (exact types are maintained). Use TypeScript's type-level testing utilities to assert that satisfies produces the expected types.
Future Directions
The satisfies operator represents a broader trend in TypeScript toward type-level programming that preserves information. Future TypeScript versions may extend this pattern to function return types, allowing functions to declare that their return value satisfies a type without widening it.
The integration of satisfies with other type system features continues to evolve. Recent TypeScript versions have improved inference in satisfies expressions, particularly for complex nested types and conditional type parameters. The community is also exploring patterns that combine satisfies with template literal types for even more precise validation.
Conclusion
The satisfies operator bridges the gap between type safety and type precision in TypeScript. It validates that expressions match a type without losing literal type information, making it ideal for configuration objects, route definitions, theme systems, and API schemas where exact values matter.
Key takeaways for using satisfies effectively:
satisfieschecks types without widening β preserves literal types while validating structure- Best for configuration, route, and schema objects where exact values carry important information
- Catches errors at definition site rather than at usage site, improving developer experience
- No runtime cost β purely a compile-time check that TypeScript erases during compilation
- Complements
as constβ use together for maximum type safety on immutable configurations - Prefer over type assertions (
as) β provides validation instead of bypassing type checking - Combine with discriminated unions for type-safe action creators and state machines
- Apply to lookup tables and event maps to preserve specific value types while validating structure
The satisfies operator is a small addition to TypeScript's syntax that has an outsized impact on code quality. By enabling both validation and precision, it eliminates the false choice between safety and expressiveness that developers previously faced. Incorporate satisfies into your TypeScript codebase to write more precise, self-documenting, and error-resistant code.