Skip to content

The TypeScript satisfies Operator: Config Without Loss

How satisfies Gives You Validation and Inference

Typing a config object either validates its shape or preserves literal inference, never both, until satisfies. Here's exactly what trade-off it removes.

· · 4 min read

Quick Take

Every config object I'd typed before satisfies forced a choice: annotate it and lose the literal types, or leave it untyped and lose validation. I didn't realize that was a false choice until the operator that fixes it shipped.

The config object is the case that comes up in nearly every project: a plain object literal that needs to match some interface (correct keys, correct value shapes) while also keeping its most specific inferred types available for downstream code, like a routing config where each route's method needs to stay 'GET' | 'POST', not widen to string.

TL;DR: value satisfies Type validates that value matches Type's shape at compile time, exactly like a type annotation would, but without changing the variable's inferred type afterward. A plain annotation (const x: Type = value) validates and widens; a type assertion (value as Type) widens without validating; satisfies is the missing third option: validates without widening.

The False Choice Before satisfies

interface RouteConfig {
  method: string;
  path: string;
}

// Option A: explicit annotation. Validates, but widens.
const routeA: RouteConfig = {
  method: 'GET',
  path: '/users',
};
// routeA.method is typed as `string`, not the literal 'GET'
// so this compiles even though it shouldn't be allowed downstream:
function handleGet(method: 'GET') { /* ... */ }
handleGet(routeA.method); // Error: string is not assignable to 'GET'

// Option B: no annotation. Preserves literal types, but no validation.
const routeB = {
  methdo: 'GET', // typo, no error, this key is silently wrong
  path: '/users',
};

Option A validates the shape but loses the literal 'GET' type, routeA.method becomes a general string, which breaks code that specifically needs the narrower literal. Option B keeps the literal types but catches nothing, the typo methdo compiles without complaint because there's no interface checking it against.

The Same Config With satisfies

interface RouteConfig {
  method: string;
  path: string;
}

const route = {
  method: 'GET',
  path: '/users',
} satisfies RouteConfig;

// route.method is still the literal type 'GET', not widened to string
function handleGet(method: 'GET') { /* ... */ }
handleGet(route.method); // Compiles fine, route.method is 'GET'

const routeWithTypo = {
  methdo: 'GET', // Error: Object literal may only specify known properties,
  path: '/users', //        and 'methdo' does not exist in type 'RouteConfig'
} satisfies RouteConfig;

satisfies RouteConfig checks the object literal against the interface at the point of declaration, the typo is caught immediately, exactly like Option A. But route's inferred type afterward is its own literal shape, { method: 'GET'; path: '/users' }, not widened to RouteConfig, so route.method keeps its narrow 'GET' literal type for anything downstream that needs it.

Combined With as const for Immutable, Validated Config

interface ThemeConfig {
  colors: Record<string, string>;
  spacing: Record<string, number>;
}

const theme = {
  colors: { primary: '#2563eb', secondary: '#64748b' },
  spacing: { sm: 8, md: 16, lg: 24 },
} as const satisfies ThemeConfig;

// theme.colors.primary is the literal '#2563eb', readonly
// and TypeScript verified the whole shape matches ThemeConfig

as const makes every property readonly and narrows to literals; satisfies ThemeConfig verifies the shape matches without disturbing that narrowing. This combination is the pattern worth reaching for by default on any config object that's meant to be both validated and immutable, a route table, a theme definition, an environment-variable schema.

A Real Case: A Typed Command Registry

interface Command {
  description: string;
  handler: (args: string[]) => Promise<void>;
}

const commands = {
  build: {
    description: 'Build the project',
    handler: async (args) => { /* ... */ },
  },
  deploy: {
    description: 'Deploy to production',
    handler: async (args) => { /* ... */ },
  },
} satisfies Record<string, Command>;

// TypeScript knows the exact keys: 'build' | 'deploy'
type CommandName = keyof typeof commands;

function runCommand(name: CommandName) {
  return commands[name].handler([]);
}

Without satisfies, typing commands as Record<string, Command> directly would widen keyof typeof commands to string, losing the specific 'build' | 'deploy' union that makes runCommand's parameter meaningfully typed instead of accepting any string. satisfies keeps that union intact while still verifying every command object actually has a description and handler matching the interface.

Old Way vs Modern Way

ApproachValidates shapePreserves literal types
const x: Type = valueYesNo, widens to Type's declared types
const x = value as TypeNo, silently trusts youNo, forces the asserted type
const x = value (no annotation)NoYes
const x = value satisfies TypeYesYes

Conclusion

satisfies doesn't add new type-checking power, it removes a trade-off that shouldn't have existed: validating an object's shape against an interface used to mean giving up the precise literal types that made the object useful for anything beyond that validation. For any config, route table, or lookup object where both matter, satisfies is the version to reach for by default.

Frequently Asked Questions

What problem does the satisfies operator solve?
Before satisfies, typing a variable with an explicit annotation (const config: Config = {...}) validated its shape against Config but widened literal types to their general form, a color: 'blue' as string, not the literal 'blue'. Leaving the annotation off preserved literal inference but gave up shape validation entirely. satisfies validates the value against a type without changing the inferred type of the variable itself, giving you both at once.
How is satisfies different from a type assertion (as)?
as tells the compiler to trust you and silently overrides the inferred type, with no validation, if the value doesn't actually match the asserted type, TypeScript won't catch it. satisfies is validated: if the value doesn't structurally match the type, TypeScript raises a compile error, exactly like a normal type annotation does. The difference from a normal annotation is that satisfies doesn't change what type the variable is inferred as afterward; the value keeps its own most specific inferred type.
Can satisfies be used with as const together?
Yes, and that combination is one of the more common patterns: value satisfies Type as a means to validate the value's shape while still keeping the literal inference and readonly nature that as const provides. They serve different purposes, as const makes properties readonly and narrows to literals, satisfies validates shape without widening, and using both together gets you validated, literal, immutable config in one expression.