I was in a PR review last Tuesday when a colleague highlighted a line, typed one comment, and killed twenty minutes of my day: “why as instead of satisfies?” The answer was the honest one: muscle memory. I’ve been writing TypeScript since the 3.x days and as SomeType is baked into my fingers like a keyboard shortcut. satisfies has been around since TypeScript 4.9, which means I’ve had it available for more than three years, and I still forget it exists half the time.
So this post is me writing it down so I stop forgetting. If you’re in the same boat, hopefully it saves you a review comment.
Short version for the impatient: satisfies is the operator you reach for when you want TypeScript to check that your value matches a type without widening or losing the specific thing you wrote. as is the escape hatch that turns the type system off. They look similar. They are not the same.
What satisfies actually does, in one sentence
satisfies T tells the compiler: verify that this expression is assignable to T, but keep the inferred narrow type so I get autocomplete and literal inference.
That’s it. Compare it to as T, which tells the compiler: trust me, treat this as T, never mind what it actually is. One is a check. The other is a cast.
Here’s the minimal example that made it click for me:
type RouteMap = Record<string, { method: "GET" | "POST" }>;
// The 'as' way — lossy
const routesA = {
users: { method: "GET" },
signup: { method: "POST" },
} as RouteMap;
routesA.users.method; // type is "GET" | "POST", not "GET"
// The 'satisfies' way — keeps the literal
const routesB = {
users: { method: "GET" },
signup: { method: "POST" },
} satisfies RouteMap;
routesB.users.method; // type is "GET". The specific one.
Both compile. routesA silently loses the fact that users.method is "GET". routesB keeps it. The second you want to use that narrow type in a switch, a router, or a state machine, the as version falls over and the satisfies version doesn’t.
The three places I actually reach for it
I went back through the last couple of real codebases I worked on and the same patterns kept showing up.
1. Config objects with a loose schema
Any object that’s a lookup table but whose values each have a specific shape. Route maps, feature flags, error codes, copy tables, env schemas. The table has a union type, but each key’s value is narrow. This is the single biggest use case.
type FeatureFlag = { enabled: boolean; rollout: number };
type Flags = Record<string, FeatureFlag>;
const flags = {
newBilling: { enabled: true, rollout: 100 },
sidebarV2: { enabled: false, rollout: 0 },
aiSuggestion: { enabled: true, rollout: 25 },
} satisfies Flags;
// flags.newBilling still has rollout: 100 as a literal.
// Typos on keys are still caught:
flags.sidebarv2; // error — did you mean sidebarV2?
Without satisfies, the typo check disappears (because Record<string, ...> accepts any key). Without the Flags annotation, the shape check disappears. Both, together, with satisfies, and you get both.
2. Tuples and as const companions
If you write a tuple of known values, satisfies lets you assert the shape without fighting the literal types. I use this for permission arrays, valid status sequences, and hardcoded menus.
const statuses = ["draft", "review", "published", "archived"] as const satisfies readonly string[];
type Status = typeof statuses[number];
// "draft" | "review" | "published" | "archived"
as const keeps the literal types. satisfies readonly string[] is a guardrail. If someone adds 42 to the array, it fails to compile, and I don’t have to invent a more elaborate type just to express that intent.
3. Typed return values from functions I want to keep narrow
Rarer, but useful. If I want a function’s return to be checked against an interface while callers still see the specific keys:
interface Event { type: string; payload: unknown }
function makeClickEvent(id: string) {
return { type: "click", payload: { id } } satisfies Event;
}
// Callers see `type: "click"` exactly, not `string`.
I’ve been writing more of these since I started leaning on Go generics for similar ergonomics, and the three patterns I actually use there map surprisingly well onto this TypeScript workflow.
as versus satisfies: stop using as to silence the compiler
This is the part I wish someone had hit me with three years ago.
as is useful for exactly two things. First, telling the compiler something it genuinely cannot know, like parsing an unknown JSON response or narrowing after a runtime check. Second, the as const idiom itself. Every other as in a modern TypeScript codebase is a code smell.
If you find yourself writing someValue as MyType to make the compiler stop complaining, try swapping in satisfies first. If satisfies fails to compile, as was hiding a real bug. If satisfies passes, you didn’t need as in the first place.
The TypeScript team’s own 4.9 release notes lead with this exact framing, and the handbook entry walks through the same example more carefully than I can here.
I had one project where running a codemod to turn inappropriate as into satisfies surfaced four real bugs in an afternoon. Two of them were stale types that no longer matched the shape of the data. I would not have caught those without the compiler complaining.
The thing I got wrong: satisfies doesn’t widen
A subtlety I burned an hour on. satisfies does not change the type of the expression. If you need the resulting variable to have the wider type, for example so you can assign more keys to it later, satisfies is the wrong tool.
const flags = {
newBilling: { enabled: true, rollout: 100 },
} satisfies Record<string, FeatureFlag>;
flags.anotherFlag = { enabled: true, rollout: 50 };
// error: property 'anotherFlag' does not exist
If you want a mutable map, annotate the variable normally:
const flags: Record<string, FeatureFlag> = { /* ... */ };
For the mixed case where you want to keep the narrow type and still allow mutation, declare a fresh mutable version from the narrow one. Don’t try to make satisfies do both jobs. It won’t.
Where I still reach for as
Three spots where satisfies doesn’t help:
- Parsing unknown input.
JSON.parsereturnsany. After a runtime check or a Zod/Valibot parse,as MyTypeis honest because you verified the shape at runtime. - Narrowing after a discriminant check the compiler can’t follow. Rare in modern TS, but it happens inside library internals.
as constitself. Still the right tool for freezing literal types. Pair it withsatisfiesfor guarantees.
Anywhere else, my rule is: if your instinct is to write as, write satisfies first and see what breaks.
satisfies and const type parameters are quietly powerful together
TypeScript 5.0 added const type parameters, which let generics preserve literal types at call sites. Combined with satisfies, you can build tiny DSLs that keep their narrow types without an explosion of type gymnastics. The 5.0 announcement covers the feature in detail.
Quick example — a function that takes a config object and remembers each key’s specific value:
function defineConfig<const T>(cfg: T) { return cfg; }
const cfg = defineConfig({
env: "production",
retries: 3,
} satisfies { env: string; retries: number });
cfg.env; // "production", not string
This isn’t revolutionary. It’s type ergonomics getting a little better, which is usually the difference between types that help and types that annoy. I wrote about a similar “quiet feature I keep forgetting” vibe in the useOptimistic hook I keep forgetting about — the unglamorous additions usually change how I write code more than the tentpole features do.
A thing you can try this week
Open your last TypeScript PR. Search the diff for as. For every match that isn’t as const or a deliberate cast of an unknown value, try rewriting it as satisfies. Some will fail to compile. Those are the interesting ones. Read them carefully before you revert.
That’s the whole practice. It took me three years to make it a habit, and I still miss some. If you want to see the kind of projects where I end up caring about this stuff in production, I keep a running list on my work page — most of it is TypeScript and Node, so the pattern shows up a lot.