TypeScript's satisfies Operator: Validate Without Widening
satisfies validates without widening. Unlike type annotations, it preserves literal types and enables better narrowing downstream.
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
satisfiesvalidates 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 constfor immutable, validated, literal-typed objects - Still use explicit annotations for function return types and when you need the wider type
satisfiesis a compile-time check—zero runtime cost
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement