Problem Solved

TypeScript · satisfies

Home › Courses › TypeScript › Problems › satisfies
🔷 Problem · 5 min read

When to use satisfies.
Check without widening.

Before TypeScript 4.9, you had two choices: annotate with a type and lose literal types, or leave the value inferred and get no shape check. The satisfies operator gives you both — check that a value matches a shape, while keeping its narrow inferred type. This is the operator that fixed config objects and route tables.

Short answer: Use satisfies SomeType when you want to verify a value matches a type without widening to that type. It keeps the specific literal types of the value (e.g. 'admin', not string) while still enforcing the shape. Use a type annotation when widening is fine. Use as only when you need to force a type. satisfies is the safe middle ground.

✅ Solution 1 — satisfies vs type annotation

Same check, different result — annotation widens, satisfies doesn't.

The difference is subtle but important. A type annotation says "this value IS this type" — the compiler widens the value to match. satisfies says "this value MUST match this type" — the compiler checks it but keeps the narrower literal type. When the exact literal matters downstream (as a discriminant, a route name, a config key), satisfies is the answer.

satisfies.ts
// ─── The setup ───────────────────────────────
type Route = 'home' | 'about' | 'blog';
type Routes = Recordstring>;

// ─── Annotation WIDENS (loses literal types) ─
const routes1: Routes = {
home: '/',
about: '/about',
blog: '/blog'
};
// routes1.home: string
// ↑ widened — can't use routes1 keys as a Route union downstream

// ─── satisfies KEEPS literal types ───────────
const routes2 = {
home: '/',
about: '/about',
blog: '/blog'
} satisfies Routes;

// routes2.home: '/' (literal, not string)
// keys: 'home' | 'about' | 'blog' (kept as literals)
// AND it's checked against Routes — missing key = error

// ─── What satisfies catches ───────────────────
const bad = {
home: '/',
about: '/about'
// ❌ missing 'blog' — satisfies Routes fails
} satisfies Routes;

// ─── Real use: discriminant lookup ───────────
type Status = 'idle' | 'loading' | 'success' | 'error';

const statusLabels = {
idle: 'Ready',
loading: 'Loading…',
success: 'Done',
error: 'Failed'
} satisfies Recordstring>;

// Now you can do:
// function getStatusLabel(s: Status): string {
// return statusLabels[s]; // s is Status, keys match
// }

console.log(routes2.home, statusLabels.success);
// satisfies erased at compile time. Output is identical to without it.

const routes1 = {
home: '/',
about: '/about',
blog: '/blog'
};

const routes2 = {
home: '/',
about: '/about',
blog: '/blog'
};

const bad = {
home: '/',
about: '/about'
};

const statusLabels = {
idle: 'Ready',
loading: 'Loading…',
success: 'Done',
error: 'Failed'
};

console.log(routes2.home, statusLabels.success);

Annotation widens

const x: Type = value — the value becomes exactly Type. Literal types are widened ('/' → string).

satisfies checks without widening

const x = value satisfies Type — x keeps its narrow literal types, but is verified against Type. Shape check + literal precision.

Catches missing/wrong

If the value doesn't match the type (missing key, wrong value type), satisfies errors. Same check strength as annotation.

Discriminant lookups

For Record lookups, satisfies keeps statusLabels keys as Status literals — perfect for indexing with a Status value.

satisfies replaces the "as const + annotation" dance. Before 4.9, the only way to get literal types AND a shape check was const routes = { ... } as const satisfies Routes — with as const alone you got literals but no shape check; with annotation alone you got the check but no literals. satisfies does both in one word. If you find yourself writing as const just to preserve literal types on an object that should also match a shape, you want satisfies instead.

✅ Solution 2 — satisfies vs as (check vs cast)

One verifies, the other lies. Never use as when satisfies will do.

Both appear after a value. They do opposite things. as is a cast — it tells the compiler "trust me, this is T," and disables checking. satisfies is a check — it verifies the value actually matches T and errors if not. When you want correctness, satisfies is always the right tool.

satisfies-vs-as.ts
interface Config {
host: string;
port: number;
}

// ─── as lies — no checking ─────────────────
const bad = {
host: 'localhost',
port: 'not a number' // ⚠️ wrong type
} as Config;
// ✅ compiles — as trusts you
// ❌ runtime bugs — port is actually a string

// ─── satisfies checks — errors if wrong ────
const good = {
host: 'localhost',
port: 3000
} satisfies Config;

const wrong = {
host: 'localhost',
port: 'not a number'
// ❌ Type 'string' is not assignable to type 'number'
} satisfies Config;

// ─── When as is actually needed ────────────
// as is legitimate when you're converting a type
// that the compiler can't express — e.g. narrowing
// a widened JSON.parse result after a real check:

function parseConfig(json: string): Config {
const raw = JSON.parse(json) as unknown;
// ... validate with a type guard, then return ...
return raw as Config; // after the guard, this is safe
}

// ─── satisfies with generics ───────────────
type ConfigMap = Record<'dev' | 'prod', Config>;

const configs = {
dev: { host: 'localhost', port: 3000 },
prod: { host: 'api.example.com', port: 443 }
} satisfies ConfigMap;
// Both dev and prod checked against Config
// All keys preserved as literals

// ─── The rule ────────────────────────────────
// Want to check? → satisfies
// Want to force? → as (rarely)
// Want a type? → annotation (: Type)
//
// Prefer satisfies over as in almost every case.

console.log(good.port, configs.prod.port);
const bad = {
host: 'localhost',
port: 'not a number'
};

const good = {
host: 'localhost',
port: 3000
};

const wrong = {
host: 'localhost',
port: 'not a number'
};

function parseConfig(json) {
const raw = JSON.parse(json);
return raw;
}

const configs = {
dev: { host: 'localhost', port: 3000 },
prod: { host: 'api.example.com', port: 443 }
};

console.log(good.port, configs.prod.port);

as = trust me

Disables checking. Wrong types compile silently. Use only when you know the runtime type better than the compiler can express.

satisfies = verify

Runs the same check as annotation. Wrong types are compile errors. The value keeps its narrow type.

Post-validation as is legit

After a type guard proves the shape, as T is safe — the guard did the verification. Before, as is a lie.

Generics + satisfies

satisfies ConfigMap checks every nested value against Config. No as needed anywhere.

✅ Solution 3 — satisfies with as const

Literal types AND shape validation, in one expression.

The most powerful combination: as const freezes every value to its literal type, then satisfies validates the shape. This is the idiom for lookup tables where you need every value to be exact — event maps, routes, config schemas, i18n dictionaries.

as-const-satisfies.ts
// ─── i18n dictionary ─────────────────────────
type Locale = 'en' | 'fr' | 'es';
type Messages = RecordRecord<'greeting' | 'farewell', string>>;

const messages = {
en: { greeting: 'Hello', farewell: 'Goodbye' },
fr: { greeting: 'Bonjour', farewell: 'Au revoir' },
es: { greeting: 'Hola', farewell: 'Adiós' }
} as const satisfies Messages;

// messages.en.greeting: 'Hello' (literal, not string)
// All locales present — checked by satisfies
// All message keys present — checked by satisfies

// ─── Config schemas ──────────────────────────
type Env = 'development' | 'production';
interface EnvConfig {
apiUrl: string;
debug: boolean;
logLevel: 'error' | 'warn' | 'info' | 'debug';
}

const envConfig = {
development: {
apiUrl: 'http://localhost:3000',
debug: true,
logLevel: 'debug'
},
production: {
apiUrl: 'https://api.example.com',
debug: false,
logLevel: 'error'
}
} as const satisfies Record;

// envConfig.development.apiUrl: 'http://localhost:3000' (literal)
// logLevel: 'debug' (not 'error' | 'warn' | ... — exactly what you wrote)

// ─── Typed event map ─────────────────────────
type EventName = 'click' | 'submit' | 'change';

const eventLabels = {
click: 'Clicked',
submit: 'Submitted',
change: 'Changed'
} as const satisfies Recordstring>;

function label(e: EventName): typeof eventLabels[EventName] {
return eventLabels[e];
}

// ─── The rule ────────────────────────────────
// as const → freeze literal types
// satisfies Type → check against shape
// combined → both, in one expression
//
// This is the modern idiom for config objects
// and lookup tables. Use it whenever you have
// a constant that needs both precision and safety.

console.log(messages.en.greeting);
console.log(envConfig.production.logLevel);
console.log(label('click'));
const messages = {
en: { greeting: 'Hello', farewell: 'Goodbye' },
fr: { greeting: 'Bonjour', farewell: 'Au revoir' },
es: { greeting: 'Hola', farewell: 'Adiós' }
};

const envConfig = {
development: {
apiUrl: 'http://localhost:3000',
debug: true,
logLevel: 'debug'
},
production: {
apiUrl: 'https://api.example.com',
debug: false,
logLevel: 'error'
}
};

const eventLabels = {
click: 'Clicked',
submit: 'Submitted',
change: 'Changed'
};

function label(e) {
return eventLabels[e];
}

console.log(messages.en.greeting);
console.log(envConfig.production.logLevel);
console.log(label('click'));

as const alone

Freezes literal types but doesn't check the shape. { a: 'x', b: 'y' } as const — no shape validation.

satisfies alone

Checks the shape but values stay widened (unless the source is already literal). string, not 'Hello'.

Combined

as const satisfies T — literal types AND shape check. The idiom for any lookup table where every value must be exact.

Order matters

as const satisfies T — as const first, then satisfies. Reversing breaks the narrowing. Follow the standard order.

Don't use satisfies when the value should be widened. If a config value is 'http://localhost:3000' and you want it typed as string (not the literal), use a type annotation, not satisfies. satisfies is for cases where the exact literal matters downstream. If it doesn't matter, annotation is simpler and clearer. Use satisfies deliberately — not as a replacement for annotation everywhere.

⚠️ Common mistakes

The satisfies operator traps that cost real time.

1. Using satisfies where annotation is fine

If you don't need the literal type, annotation is simpler. Fix: use satisfies only when preserving narrow types.

2. Using as instead of satisfies

as Config disables checking. If you want verification, use satisfies Config. Fix: prefer satisfies for checks, as only for genuine casts.

3. Forgetting as const before satisfies

Without as const, values widen. satisfies checks the widened type, not the literal. Fix: as const satisfies T.

4. Reversing the order

satisfies T as const — syntax error or broken narrowing. Fix: as const first, then satisfies T.

5. Expecting runtime behavior

satisfies is compile-time only. It doesn't validate at runtime. Fix: for runtime validation, use a type guard or a schema library (Zod, Valibot).

6. Using satisfies on function bodies

satisfies only works on expressions. You can't satisfies a function's body — annotate the return type instead.

7. Confusing satisfies with a validation library

satisfies checks against a type at compile time. Zod checks at runtime. They solve different problems. Fix: use both — satisfies for the type, Zod for untrusted data.

8. Not using satisfies for discriminant lookups

Annotating Record widens keys and values, losing the discriminant. Fix: satisfies keeps both.

❓ Frequently asked

What does the satisfies operator do in TypeScript?

satisfies checks that a value matches a type without changing the value's inferred type. Without satisfies, annotating a value with a type widens it to that type — losing literal types. With satisfies, the value keeps its narrow literal type while being checked against the specified type. Syntax: const x = { ... } satisfies SomeType.

What is the difference between satisfies and as?

as is a cast — it tells TypeScript 'trust me, this is that type' and doesn't check. satisfies is a check — it verifies the value matches the type and errors if it doesn't. as can lie; satisfies cannot.

When should I use satisfies instead of a type annotation?

Use satisfies when you want the value to keep its narrow literal type AND match a shape. Classic case: a config object where you want every field checked against a Config type, but you also want to keep the specific string literal for each field.

Does satisfies exist at runtime?

No. satisfies is a compile-time-only operator. It's erased at build time — the output JavaScript is identical to the code without it. Its only job is to run a type check during compilation.

🔗 Related

← All TypeScript problems
Problem solved · TypeScript