Skip to content

TypeScript satisfies: When I Stopped Reaching for as

TypeScript satisfies: When I Stopped Reaching for as

Short version for the impatient: satisfies is the TypeScript keyword I now reach for whenever I want the compiler to check that a value matches a type, without throwing away what the compiler already knows about that value. If you’ve ever typed as SomeType and then sighed because you knew you were lying to the compiler a little, this is for you.

I was reviewing a pull request from someone on my team last week and saw three as Foo casts on a config object. The author wasn’t wrong, exactly, but every one of those casts was a tiny bit of trust the compiler was being asked to give up. I left a single comment that just said try satisfies. The diff that came back was shorter, safer, and the IDE got smarter about autocomplete inside the object. That’s the post.

Why as always made me a little nervous

I’ll start with my honest opinion of as. It’s a hammer. It tells the compiler “trust me, I know what this is.” It works. It’s also been the cause of approximately every prod incident I’ve shipped that wasn’t a missing await.

The usual pattern looks like this:

type Theme = {
  light: { bg: string; fg: string };
  dark: { bg: string; fg: string };
};

const theme = {
  light: { bg: "#fff", fg: "#111" },
  dark:  { bg: "#0b0b0b", fg: "#eaeaea" },
} as Theme;

This compiles. It also silently allows you to write dark: { bg: "#0b0b0b" } (missing fg) and never hear about it, because as widens the assertion. The compiler doesn’t check that your literal actually fits the type. It just nods and walks away.

The other usual fix is a type annotation:

const theme: Theme = {
  light: { bg: "#fff", fg: "#111" },
  dark:  { bg: "#0b0b0b", fg: "#eaeaea" },
};

Now the compiler will yell if fg is missing. But you’ve also told it the type is Theme, which means theme.light.bg is just string, not the literal "#fff". If you wanted to derive a union of allowed background colours from this object, you’ve lost that info. Annoying.

What satisfies actually does

satisfies, introduced in TypeScript 4.9, splits those two jobs apart. It checks that the value fits the type. It does not change the inferred type of the value.

const theme = {
  light: { bg: "#fff", fg: "#111" },
  dark:  { bg: "#0b0b0b", fg: "#eaeaea" },
} satisfies Theme;

// theme.light.bg is now "#fff", not string
// missing fg in dark is now a compile error

That’s the whole feature. Read it twice, because it took me a couple of months to internalise that those are different things.

The TypeScript handbook page on satisfies has the canonical example with palettes and hex codes. It’s fine. But the version that finally got it into my head was config objects, which is what I’ll use here.

Where it earns its keep in my codebase

I now use satisfies in three places without thinking, and I want to walk through them quickly because nobody else writes about these as a group.

Route or feature maps

A Next.js app I worked on had a routes map that looked like this:

const routes = {
  home: "/",
  blog: "/blog",
  post: (slug: string) => `/blog/${slug}`,
} as const;

This was fine until someone wanted a type that said “the name of any route”. With a Record<string, ...> annotation we’d have lost the literal keys. With as Record<...> we’d have lost the function signatures. With satisfies, both are preserved:

type RouteValue = string | ((...args: never[]) => string);

const routes = {
  home: "/",
  blog: "/blog",
  post: (slug: string) => `/blog/${slug}`,
} as const satisfies Record<string, RouteValue>;

type RouteName = keyof typeof routes; // "home" | "blog" | "post"

Now RouteName is the actual union of keys, the function is still callable with (slug: string) and not (...args: never[]), and the compiler will yell if I add a value that isn’t a string or a function returning a string. That’s three useful things from one keyword.

API response shape guards in tests

When I write a fixture for an API test, I want the fixture to be the real shape of the response, and I want the compiler to scream if the real type changes. satisfies makes the fixture self-checking:

import type { GetUserResponse } from "./api-types";

export const fakeUser = {
  id: "usr_123",
  email: "[email protected]",
  roles: ["admin", "editor"],
} satisfies GetUserResponse;

If I later add a required createdAt to GetUserResponse, the fixture breaks at compile time, not three months later in a flake. I’ve shipped at least two bugs because a test fixture was cast with as and silently went out of date. Never again, hopefully.

Discriminated unions that look obvious but aren’t

This one is mine. I had a state object like { status: "loading" } | { status: "ready", data: T }. When I built the initial value, I’d often write as const and call it a day. The problem is as const doesn’t check anything, it just narrows. So you can produce an initial value that doesn’t fit your union and TypeScript will happily generate a tighter literal type that conflicts with what you actually expect.

type State = { status: "loading" } | { status: "ready"; data: User };

const initial = { status: "loading" } as const satisfies State;

The as const keeps status as the literal "loading". The satisfies State makes sure I haven’t accidentally written something the union doesn’t allow. The two-keyword combo (as const satisfies) is now muscle memory for me on every reducer file.

Where I still don’t bother

There are two cases where satisfies is overkill and I just don’t use it.

The first is single-use literals inside function arguments. If I’m calling setOptions({ retries: 3 }) once, the inline literal already gets contextually typed against the parameter. Adding satisfies here is noise.

The second is when I genuinely need to coerce a value across a type boundary the compiler can’t reason about, usually around external library types or JSON.parse. That’s still an as cast, and I’d rather have one obvious lie than a satisfies that gives me a false sense of safety. I cover that style of “defensive cast” pattern more in my notes on TypeScript generics in production.

A small pitfall I hit twice

satisfies checks the value against the type, not the other way around. So if your value has extra properties, the compiler will complain about excess property checks when you’d rather it didn’t. For library config objects that accept extra forward-compatible keys, you sometimes still want a regular annotation. I keep a // keep the annotation here, satisfies is too strict comment in two spots for this exact reason.

Also, please don’t go around the codebase replacing every : with satisfies. The vast majority of typed variables in a normal app are fine with a plain annotation. satisfies shines when you care about preserving the exact value type, which is mostly true for constants, fixtures, and config maps.

What I’d do this week

If you’re on TypeScript 4.9 or newer, grep your codebase for as outside of test files and JSX. Look for the patterns I described, config objects, route maps, fixtures, initial state, and try satisfies instead. You’ll probably delete a few quiet casts and gain better autocomplete in the process.

If you write a lot of typed configs and want a second pair of eyes on a real codebase, that’s the kind of work I take on as a freelancer; you can see some recent client work here. And if satisfies finally makes a tricky union click for you, I’d love to hear which one.