Understanding TypeScript's satisfies Operator
TypeScript's satisfies operator gives you the best of both worlds: strict type checking plus precise type inference. Introduced in TypeScript 4.9, it solves a common frustration where you had to choose between validating your object's structure or keeping its specific types.
Introduction to satisfies
Here's the problem satisfies solves: you want to ensure an object matches a type, but you don't want TypeScript to widen your specific values into generic types. With traditional type assertions, you either lose type precision or skip validation entirely.
The satisfies operator validates that your object conforms to a type while preserving the literal types of its properties. You can even include extra properties beyond what the type requires. For example:
interface Animal {
name: string;
sound: string;
}
const dog = {
name: "Buddy",
sound: "Woof",
// Extra properties are allowed with satisfies
breed: "Golden Retriever"
} satisfies Animal;
// TypeScript knows 'breed' exists
console.log(dog.breed); // Works perfectly
// But still validates required properties
const cat = {
name: "Whiskers"
// Error: Property 'sound' is missing
} satisfies Animal;
This code checks that dog has all required properties from Animal. TypeScript still knows about the breed property and keeps the specific string literals instead of widening to just string.
The Key Benefit: Literal Type Preservation
Why does satisfies exist when we already have type annotations? Because it keeps your types specific instead of widening them. This is the operator's superpower.
Compare these two approaches:
type ApiConfig = {
host: string;
port: number;
timeout: number;
};
// With type annotation - types get widened
const config1: ApiConfig = {
host: 'localhost',
port: 8080,
timeout: 3000
};
// config1.port is type 'number'
// config1.host is type 'string'
// With satisfies - literal types preserved
const config2 = {
host: 'localhost',
port: 8080,
timeout: 3000
} satisfies ApiConfig;
// config2.port is type '8080' (literal)
// config2.host is type 'localhost' (literal)
So what? Well, when you need those exact values elsewhere in your code, TypeScript can provide better autocomplete and catch more bugs:
type Environment = 'localhost' | 'staging' | 'production';
// This works because TypeScript knows host is exactly 'localhost'
const env: Environment = config2.host;
// This fails because TypeScript only knows host is some string
const env2: Environment = config1.host; // Error!
With Convex, this precision helps catch configuration errors before they hit production.
satisfies vs : vs as: Choosing the Right Approach
TypeScript gives you three ways to work with types, and knowing when to use each one prevents bugs and keeps your code clean.
Type Annotations (:)
Type annotations define a contract for your variable's lifetime:
let currentPort: number = 8080;
currentPort = 9000; // Fine, still a number
currentPort = "8080"; // Error: Can't assign string
Use annotations when you need a variable to accept a range of values over time, or when you want to establish clear contracts for function parameters and return types.
Type Assertions (as)
Type assertions tell TypeScript "trust me, I know better":
const element = document.getElementById('app') as HTMLDivElement;
Here's the danger: TypeScript doesn't validate your assertion. If that element is actually a <span>, your code fails at runtime. Use as sparingly, typically only for DOM manipulation or when working with external data where you've done runtime validation.
The satisfies Operator
satisfies validates your object matches a type but keeps its precise shape:
type RouteConfig = {
path: string;
handler: Function;
};
const routes = {
home: { path: '/', handler: () => 'Home' },
about: { path: '/about', handler: () => 'About' }
} satisfies Record<string, RouteConfig>;
// TypeScript knows exactly which routes exist
routes.home; // Works
routes.contact; // Error: Property doesn't exist
The decision framework:
- Default to
:annotations for variables, parameters, and return types - Use
satisfieswhen you need exact object shapes validated without widening - Reserve
asfor unavoidable scenarios like DOM manipulation or post-validation assertions
Using satisfies to Enforce Type Constraints
Combine satisfies with type definitions to catch structural errors while maintaining type precision. This works particularly well for configuration objects where you want both validation and autocomplete:
type DatabaseConfig = {
host: string;
port: number;
ssl: boolean;
};
const prodConfig = {
host: 'db.example.com',
port: 5432,
ssl: true,
// Additional properties are fine
poolSize: 20
} satisfies DatabaseConfig;
// TypeScript validates required fields are present
const invalidConfig = {
host: 'db.example.com',
// Error: 'port' is required
ssl: true
} satisfies DatabaseConfig;
When working with Convex, this pattern ensures your configuration objects strictly match their expected types while allowing environment-specific extensions.
Validating Object Shapes with satisfies
Use satisfies to confirm objects match specific types while keeping flexibility for additional properties. This shines when working with interfaces and complex object shapes:
type User = {
id: number;
name: string;
email?: string;
};
const user = {
id: 1,
name: 'John Doe',
email: 'john.doe@example.com',
// Extra metadata is allowed
preferences: {
notifications: true,
theme: 'dark'
},
lastLogin: new Date()
} satisfies User;
// TypeScript knows about all properties
console.log(user.preferences.theme); // Works perfectly
// But validates the core structure
const invalidUser = {
id: 'not-a-number', // Error: Type 'string' is not assignable to type 'number'
name: 'Jane Doe'
} satisfies User;
Here's what matters: satisfies checks that your object has the required properties with correct types, but doesn't block you from adding extras. Explore more TypeScript insights.
Combining as const satisfies for Immutability
Here's a powerful pattern: combine as const with satisfies to get both immutability and type validation. This is perfect for configuration objects and lookup tables that should never change:
type StatusConfig = {
code: number;
message: string;
retryable: boolean;
};
const errorStatuses = {
notFound: { code: 404, message: 'Not Found', retryable: false },
serverError: { code: 500, message: 'Server Error', retryable: true },
timeout: { code: 408, message: 'Timeout', retryable: true }
} as const satisfies Record<string, StatusConfig>;
// All properties are readonly
errorStatuses.notFound.code = 500; // Error: Cannot assign to 'code' because it is a read-only property
// TypeScript preserves exact literal types
type NotFoundCode = typeof errorStatuses.notFound.code; // Type is 404, not number
// But still validates structure
const invalidStatuses = {
badRequest: { code: '400', message: 'Bad Request', retryable: false }
// Error: Type 'string' is not assignable to type 'number'
} as const satisfies Record<string, StatusConfig>;
This pattern stops accidental changes and keeps your objects matching the required structure. You get compile-time validation plus runtime immutability.
Ensuring Stricter Type Checking with satisfies
For stricter type checking, combine satisfies with detailed type constraints. This prevents errors early and ensures objects conform to expected structures:
type Permission = 'read' | 'write' | 'delete' | 'admin';
type Role = {
id: number;
name: string;
permissions: Permission[];
};
const adminRole = {
id: 1,
name: 'Admin',
permissions: ['read', 'write', 'delete'] as Permission[],
// Additional context is preserved
department: 'IT',
createdAt: new Date()
} satisfies Role;
// Type-safe validation function
const validateRole = (role: Role) => {
if (role.permissions.length === 0) {
throw new Error('Role must have at least one permission');
}
return role;
};
const validatedAdminRole = validateRole(adminRole);
// TypeScript catches permission typos
const invalidRole = {
id: 2,
name: 'Editor',
permissions: ['read', 'wright'] // Error: 'wright' is not assignable to type 'Permission'
} satisfies Role;
This approach combines compile-time type validation with runtime checks for complete type safety.
Implementing satisfies for Better Type Safety
When working with generics, satisfies provides solid validation while maintaining flexible object structures:
type Component<T = Record<string, any>> = {
id: number;
name: string;
props: T;
};
const buttonComponent = {
id: 1,
name: 'Button',
props: {
label: 'Click me',
onClick: () => console.log('Button clicked'),
className: 'primary-button',
variant: 'solid' as const
},
// Component metadata
version: '1.0.0'
} satisfies Component;
// TypeScript infers exact prop types
type ButtonProps = typeof buttonComponent.props;
// { label: string; onClick: () => void; className: string; variant: 'solid' }
// You can extract and reuse the component type
type ValidComponent = typeof buttonComponent;
The satisfies operator ensures your component matches the structure while TypeScript infers the most specific types for your props.
Writing Accurate Type Definitions with satisfies
Use satisfies to validate complex data structures while allowing flexibility for metadata and extensions:
type FormField = {
type: string;
value: any;
required?: boolean;
};
type Form = {
id: number;
fields: Record<string, FormField>;
};
const loginForm = {
id: 1,
fields: {
username: {
type: 'text',
value: '',
required: true,
placeholder: 'Enter your username',
autocomplete: 'username'
},
password: {
type: 'password',
value: '',
required: true,
minLength: 8,
autocomplete: 'current-password'
}
},
// Form-level metadata
version: '1.0',
csrfToken: 'abc123'
} satisfies Form;
// Type-safe form processing
const processForm = (form: Form) => {
const fields = Object.entries(form.fields)
.filter(([_, field]) => field.required)
.map(([name, field]) => ({ name, ...field }));
return fields;
};
const processedLoginForm = processForm(loginForm);
This pattern validates your form structure while preserving all the extra properties you need for a real application.
Common Challenges and Solutions
Type constraints can trip you up. The satisfies operator solves these common TypeScript challenges by checking that values match specific types without losing precision. For more advanced techniques in reducing repetitive type checking, explore argument validation strategies.
Ensuring Object Shapes Match Expectations
When working with complex configurations, it's easy to add properties that don't belong. The satisfies operator catches these mistakes immediately:
type ServerConfig = {
host: string;
port: number;
};
// This catches the typo at compile time
const config = {
host: 'localhost',
port: 8080,
prot: 443 // Typo! Error: Object literal may only specify known properties
} satisfies ServerConfig;
Wait, but didn't we say satisfies allows extra properties? It does, but only for properties that could potentially be used. If you misspell a property name that looks like it should be part of the base type, TypeScript flags it as a probable error.
Achieving Type Safety Without Excessive Boilerplate
You don't need separate type declarations for every object. Use satisfies inline to validate structure while letting TypeScript infer the exact type:
type ApiEndpoint = {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
path: string;
rateLimit?: number;
};
// No separate type declaration needed
const endpoints = {
getUser: { method: 'GET', path: '/users/:id', rateLimit: 100 },
createUser: { method: 'POST', path: '/users', rateLimit: 10 },
updateUser: { method: 'PUT', path: '/users/:id', rateLimit: 50 }
} as const satisfies Record<string, ApiEndpoint>;
// TypeScript knows exact methods and paths
type GetUserMethod = typeof endpoints.getUser.method; // 'GET', not string
This keeps your code concise while maintaining type safety.
Preserving Discriminated Union Types
When working with discriminated unions, satisfies preserves the discriminator's literal type, enabling proper type narrowing:
type SuccessResponse = {
status: 'success';
data: any;
};
type ErrorResponse = {
status: 'error';
message: string;
};
type ApiResponse = SuccessResponse | ErrorResponse;
const response = {
status: 'success',
data: { id: 1, name: 'John' }
} satisfies ApiResponse;
// TypeScript knows status is exactly 'success'
if (response.status === 'success') {
// Type narrowing works perfectly
console.log(response.data);
}
Without satisfies, you'd need to either widen the type or use type assertions, losing this precision.
When to Use satisfies: Decision Framework
Choosing between type annotations and satisfies depends on your specific needs. Here's a practical decision tree:
Use type annotations (:) when:
- Defining function parameters and return types
- Creating variables that need to accept a range of values
- Establishing clear API contracts
- You want TypeScript to prevent assignment of incompatible types later
Use satisfies when:
- You need the exact inferred type, not a widened version
- Validating complex object literals against a specification
- Working with configuration objects where autocomplete matters
- Building lookup tables or discriminated unions
- Combining with
as constfor immutable, validated objects
Use type assertions (as) when:
- You've performed runtime validation and know more than TypeScript
- Working with DOM elements and need to narrow types
- Interfacing with poorly-typed third-party libraries
- You understand the risks and have no better option
Example decision process:
// Need to reassign with different values? Use annotation
let currentTheme: 'light' | 'dark' = 'light';
currentTheme = 'dark'; // Works
// Need exact object shape validated? Use satisfies
const themeConfig = {
primary: '#007bff',
secondary: '#6c757d'
} satisfies Record<string, string>;
// Need both immutability and validation? Combine them
const THEME_CONSTANTS = {
light: { primary: '#007bff' },
dark: { primary: '#1a1a1a' }
} as const satisfies Record<string, { primary: string }>;
Remember: start with type annotations as your default, reach for satisfies when precision matters, and treat as as a last resort.
Final Thoughts About TypeScript satisfies
The satisfies operator gives you precise type checking without sacrificing type inference. It fills a real gap between overly strict type annotations and risky type assertions.
Key advantages include:
-
Preserving literal types instead of widening to generic types
-
Validating object structure while allowing additional properties
-
Combining with
as constfor immutable, type-safe objects -
Enabling better autocomplete in editors through precise inference
Practical applications:
-
Configuration objects that need validation plus autocomplete
-
Discriminated unions where literal types enable type narrowing
-
Lookup tables with exact keys and values
-
Component props that require both flexibility and type safety
satisfies helps you write more reliable code by catching errors at compile time while keeping TypeScript's type inference working for you. Learn more about TypeScript techniques.