You are lying to the compiler. It is time to stop.
I am going to say something that might make some of you uncomfortable. Every time you write as in TypeScript, you are basically telling the compiler to shut up and trust you. That feels powerful in the moment. It is also how you ship bugs to production.
The as keyword does not check anything. It is a command, not a question. You are saying "I know better than you, compiler, and I don't need your help." Sometimes that is true. Most of the time it is not.
TypeScript 4.9 gave us something better. It is called satisfies. Once you understand what it does, you will wonder how you ever lived without it.
What as actually does
Let me be clear about the mechanics here. When you write:
const user = { name: "Alice" } as User;
You are doing a type assertion. You are overriding TypeScript's type inference with your own judgment. The compiler will check that the assertion is plausible. It will not verify that your object actually has all the required properties.
Here is the problem. as suppresses errors. It does not catch mistakes. It hides them.
Look at this:
interface User {
name: string;
email: string;
}
const user = {
name: "Alice",
username: "alice123"
} as User;
TypeScript will not complain. But you just created an object that claims to be a User while missing the email property and carrying an extra username field. If your code later tries to read user.email, you get undefined at runtime.
This is exactly the kind of silent failure TypeScript was supposed to prevent.
Enter satisfies
The satisfies operator does something different. Instead of overriding the type, it validates it.
Same example with satisfies:
const user = {
name: "Alice",
email: "alice@example.com"
} satisfies User;
If you misspell email as emial, or forget it entirely, TypeScript throws an error immediately. Your IDE will yell at you. The compiler will refuse to build.
But here is the beautiful part. satisfies does not widen your types.
With a type annotation like const user: User = {...}, TypeScript treats user.name as string. With satisfies, TypeScript keeps the literal type. If you wrote name: "Alice", then user.name is exactly "Alice", not just string.
The difference in practice
Let me show you a real scenario that changed how I write TypeScript.
Imagine you are defining a color palette:
type ColorConfig = Record<string, [number, number, number]>;
// With annotation, you lose precision
const colors: ColorConfig = {
red: [255, 0, 0],
green: [0, 255, 0],
blue: [0, 0, 255],
};
colors.red; // Type: [number, number, number]
colors.pink; // No error. TypeScript doesn't know "pink" doesn't exist
Now with satisfies:
const colors = {
red: [255, 0, 0],
green: [0, 255, 0],
blue: [0, 0, 255],
} satisfies ColorConfig;
colors.red; // Type: [255, 0, 0]. Precise.
colors.pink; // Error: Property 'pink' does not exist
That second example gives you autocomplete for the actual keys, catches typos, and preserves the exact literal values. This is what TypeScript was always supposed to feel like.
When you should still use as
I am not saying as is always wrong. There are legit cases where you know more than the compiler.
The classic one is DOM queries:
const element = document.querySelector("#app") as HTMLDivElement;
TypeScript cannot possibly know what element is on the page. It returns Element | null. You know it is a div. This is where as earns its keep.
Other legit uses include type guards and certain edge cases where you are avoiding any. But for object literals, config files, and type validation, which is where most of us spend our time, satisfies is the better tool.
The mental model
Here is how I think about it.
Type annotation (: Type) means "this variable is this type. I don't care about the specific value."
as means "I know better than you, compiler. Don't check."
satisfies means "make sure this value fits this shape, but keep the precise type I inferred."
The third option is almost always what you actually want.
Stop lying. Start validating.
The TypeScript community is already moving this direction. There are even lint rule proposals to prefer satisfies over as for exactly the reasons I described.
Next time you reach for as, ask yourself one question. Am I actually smarter than the compiler here, or am I just being lazy?
If it is the second one, try satisfies. Your future self will thank you.
Top comments (9)
Nice article! I agree that
satisfiesis usually a much safer choice thanas. đ¸One important distinction is that
satisfiesonly checks types at compile time. It does not validate values coming from APIs,JSON.parse,localStorage, etc. at runtime.This is also where type guards become useful. In
is-kit, for example,typedStruct<T>()combines runtime validation with compile-time checking so the guard stays aligned with the TypeScript type.So I think
satisfiesis a great alternative toas, but keeping the compile-time/runtime boundary explicit is important. đ¸Appreciate you sharing
is-kittoo; keeping runtime guards aligned with TypeScript types is always a win. đ¸The distinction is useful for static model and provider configs: satisfies catches a misspelled key without throwing away the precise inferred type. One caveat for inference apps: it checks your code at compile time, not the JSON returned by a model or API at runtime. That payload still needs validation before you trust it as a User or response type. Are you using satisfies mainly for config objects, or also to shape inference-response handling?
How are you approaching runtime validation for your LLM outputs?
Good question. I treat satisfies as a compile-time check on my contractânot proof that an LLM will honor it at runtime. Iâd validate outputs at the boundary against a schema, then add semantic checks for the rules a schema canât capture. If validation fails, make that visible and use bounded retries or repair rather than quietly coercing the result.
The tricky part is avoiding checks that reject useful answers. Are you mostly validating structured output, or also whether the answer is factually or operationally correct?
Mainly structured output validation using Zod schemas right at the API boundary, paired with bounded retries if the parsing fails. For semantic correctness, I rely more on post-processing checks and application-level business logic rather than trying to force everything into the initial schema check. Keeping runtime guards distinct from compile-time types like
satisfiesmakes handling those boundary failures much cleaner.That split makes sense: schema validation tells you the output has the right shape; application logic decides whether itâs actually safe and useful. Iâm thinking about a similar boundary at CarbonLayer as we work toward per-inference decision receipts. A carbon or water value can pass validation and still be misleading if its source, freshness, or uncertainty isnât clearâso those need to travel with the value, or it should be marked unavailable.
When an output is schema-valid but fails a semantic check, do you usually reject it outright or return it with a warning?
It depends on the failure's context and degree of severity. Attaching a warning or flag to the payload typically works best if a semantic check fails on a non-critical field or an optional rule, allowing consumers or downstream UI to handle it gracefully (displaying a warning banner, presenting an uncertainty score, or falling back to a default state). However, it is much better to reject it outright, report the failure, and either trigger a bounded retry or return a structured error if it violates a basic requirement, such as a calculation that is drastically out of bounds or missing crucial audit metadata required for your decision receipts. While preserving safety guaranties, the pipeline is kept flexible by treating semantic validity as a spectrum.
I like treating semantic validity as a spectrum rather than making every failed check a hard stop. A questionable optional field can carry a warning and let the consumer decide; a missing audit requirement or wildly implausible value should fail closed. The tricky part is making warnings structured and hard to ignoreâinclude the field, rule, and severityâso âgracefulâ doesnât quietly become ânobody noticed.â
Iâd also keep retries bounded and use them only when the failure is plausibly recoverable. Retrying a deterministic semantic violation just hides the real issue behind extra attempts. How do you decide when a field thatâs optional in the schema becomes critical enough in a particular use case that it should block the payload?