cd /news/developer-tools/satisfies-vs-type-annotation-the-typ… · home topics developer-tools article
[ARTICLE · art-26074] src=dev.to pub= topic=developer-tools verified=true sentiment=↑ positive

satisfies vs Type Annotation: The TypeScript Choice That Changes Inference

TypeScript's `satisfies` operator, introduced in version 4.9, allows developers to validate a value against a type without widening the inferred type, preserving literal keys and values. This is particularly useful for config objects, where type annotations discard specific details, while `satisfies` maintains narrow types for safer autocomplete and key checking.

read6 min publishedJun 13, 2026

You have a config object. You want the compiler to check it

against a known shape, so you reach for a type annotation:

type RouteConfig = Record<string, string>;

const routes: RouteConfig = {
  home: "/",
  users: "/users",
  billing: "/billing",
};

The check works. Add a route whose value is a number and the

compiler complains. Good. Then you try to read a route by its

key and the editor goes quiet:

routes.home;
// string

routes.dashboard;
// string  — no error, even though "dashboard"
//           is not in the object

The annotation did its job and then erased everything the

compiler had figured out on its own. routes

is now exactly

Record<string, string>

. The literal keys are gone. The literal

values are gone. You traded inference for a check.

satisfies

is the keyword that lets you keep both. It checks

the value against the type without throwing away the narrow type

the compiler inferred. It landed in TypeScript 4.9 in late 2022,

and config objects are where it earns its place.

A type annotation is a contract in one direction: the value must

be assignable to the declared type, and from that point on the

binding is the declared type. The compiler forgets the

specifics of the literal you wrote.

const colors: Record<string, string> = {
  primary: "#1a1a1a",
  accent: "#d97b2b",
};

type Keys = keyof typeof colors;
// string  — not "primary" | "accent"

keyof typeof colors

is string

, because as far as the type

system is concerned colors

is a Record<string, string>

with

arbitrary string keys. The two keys you wrote are an

implementation detail the annotation discarded.

That is fine when you want it. A function parameter typed as

Record<string, string>

should accept any such record. But for

a config object you read from, the lost detail is the whole

point of having it in source.

satisfies

checks the expression against a type and then leaves

the inferred type in place. Same check, no widening.

const colors = {
  primary: "#1a1a1a",
  accent: "#d97b2b",
} satisfies Record<string, string>;

type Keys = keyof typeof colors;
// "primary" | "accent"

colors.primary;
// "#1a1a1a"   — the literal, not string

colors.missing;
// error: Property 'missing' does not exist

The constraint Record<string, string>

still runs. Put a

number where a string belongs and you get the same error you

would with an annotation:

const broken = {
  primary: "#1a1a1a",
  accent: 0xd97b2b,
} satisfies Record<string, string>;
// error: Type 'number' is not assignable to type 'string'.

So you get the guard rail and you keep the narrow type. The key

names are real. The values are literals. Autocomplete works.

Misspelled lookups fail.

Here is a shape you have written some version of. A map from a

known set of environments to their settings.

type EnvConfig = {
  apiUrl: string;
  timeoutMs: number;
  retries: number;
};

const config = {
  dev: { apiUrl: "http://localhost", timeoutMs: 1000, retries: 0 },
  prod: { apiUrl: "https://api.app", timeoutMs: 5000, retries: 3 },
} satisfies Record<string, EnvConfig>;

Each entry is checked against EnvConfig

. Forget retries

in

one of them and the compiler points at that entry. That is the

annotation half of the job, and satisfies

does it.

Now the part an annotation throws away. Because the inferred

type survives, you can derive from it:

type Env = keyof typeof config;
// "dev" | "prod"

function load(env: Env) {
  return config[env];
}

load("dev");    // ok
load("staging"); // error: not "dev" | "prod"

Env

is the union of the actual keys. Had you annotated

config

as Record<string, EnvConfig>

, keyof typeof config

would be string

and load("staging")

would compile and blow

up at runtime when it indexed an object that has no staging

key. The narrow type the compiler kept is what makes the lookup

safe.

satisfies

is not a replacement for annotations. They answer

different questions.

An annotation says: this binding has this type, treat it that way everywhere. That is what you want for a public API surface,

export const defaults: Settings = {
  theme: "dark",
  fontSize: 14,
};

Here you want defaults

to be Settings

, not the narrow shape.

A consumer should see the documented type, and you should be

free to reassign or build defaults

differently later without

the inferred type rippling out. The annotation is the right

call.

Annotations also catch a class of error satisfies

does not:

the missing property at the point of declaration.

const a: Settings = { theme: "dark" };
// error: Property 'fontSize' is missing

const b = { theme: "dark" } satisfies Settings;
// error: Property 'fontSize' is missing

Both flag it here, so that is a wash. The real split is

direction. An annotation forces the value down to the type.

satisfies

checks the value against the type and keeps the

value's own type. When you read from the binding, you almost

always want the second. When you hand the binding to someone

else as a typed contract, you usually want the first.

You can use both when you want a checked, narrow value behind a

declared contract. The order reads outside-in: annotate the

exported name, build it with satisfies

so the internal lookups

stay sharp.

const internalRoutes = {
  home: "/",
  users: "/users",
} satisfies Record<string, string>;

// internal code gets "home" | "users" autocomplete
internalRoutes.home;

// exported surface is the wide, documented type
export const routes: Record<string, string> = internalRoutes;

Inside the module you keep the literal keys. The export presents

the contract. Two bindings, two jobs, no lost information in

either.

satisfies

checks excess properties the way an object literal

does, which can surprise you when the constraint is a union.

type Shape =
  | { kind: "circle"; r: number }
  | { kind: "square"; side: number };

const s = {
  kind: "circle",
  r: 10,
  side: 4,
} satisfies Shape;
// error: Object literal may only specify known
//        properties, and 'side' does not exist in
//        type ...

The annotation form rejects this too, so the behavior is

consistent. The thing to remember is that satisfies

does not

loosen any check. It only changes what type the binding ends up

with. If a value fails an annotation, it fails satisfies

with

the same message.

Reach for satisfies

when you read from the value and want the

compiler to remember the specifics: config maps, route tables,

lookup objects, anything where keyof typeof

or a literal value

type is useful downstream. Reach for an annotation when the

declared type is the contract you are exposing and the literal

is just today's filling.

The mistake is using an annotation on a config object you read

from, watching autocomplete go dark, and assuming that is the

cost of type safety. It is not. satisfies

gives you the check

and keeps the inference. On a config object, that is the whole

game.

If the difference between widening and constraint inference is

the kind of distinction you want laid out in full — generics,

conditional and mapped types, infer

, the machinery satisfies

sits on top of — that is what The TypeScript Type System digs

into. The config-object patterns here are the shallow end of it.

The TypeScript Library — the 5-book collection. Books 1 and 2 are the core path; 3 and 4 substitute for 1 and 2 if you come from the JVM or PHP; book 5 is for anyone shipping TS at work.

All five books ship in ebook, paperback, and hardcover.

── more in #developer-tools 4 stories · sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/satisfies-vs-type-an…] indexed:0 read:6min 2026-06-13 ·