# satisfies vs Type Annotation: The TypeScript Choice That Changes Inference

> Source: <https://dev.to/gabrielanhaia/satisfies-vs-type-annotation-the-typescript-choice-that-changes-inference-lgn>
> Published: 2026-06-13 10:05:46+00:00

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

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

``` js
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.

``` js
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.

``` js
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:

``` js
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,

``` js
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.

``` js
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.

``` js
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.*
