- Book: TypeScript Essentials
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A 500 lands in your error tracker on a Tuesday. The stack frame points at one line:
const order = (data as Order);
return await chargeCard(order.payment.cardLast4);
payment is undefined. The cast told TypeScript everything was fine. The compiler nodded, the bundle shipped, the customer's checkout page returned a JSON blob with the wrong shape because an upstream service rolled out a new event schema yesterday. Your test suite is green because the test fixture had payment populated. The cast was the lie that hid the gap.
You go to fix it. You add a null check on payment. You ship. Two weeks later a different field is missing and the same shape of bug recurs in a different file. You add another null check. The codebase grows a tax on every property access. The actual problem (the cast) is still there, in every other file that does the same thing.
TypeScript narrowing is what fixes this. Six concrete patterns take the cast out of the equation and let the compiler do the work it was built for. Most TS code uses as and stops there. The patterns below are what the next 20% of effort looks like, and they delete the bug class entirely.
For context on the version you should be on: TypeScript 6.0 shipped in early 2026, with 6.0.3 as the current patch on the 5.x→7.0 transition path. Everything below works on 5.0+; the satisfies operator landed in 4.9 (November 2022) and is now over three years old, which is to say there is no excuse for the codebases still ignoring it.
1. Discriminated unions over optional-everything
The most common shape you see in untyped-then-retro-typed codebases is the optional bag.
type Order = {
status: string;
paidAt?: Date;
refundedAt?: Date;
refundReason?: string;
shippedTrackingNumber?: string;
};
Every consumer reaches for if (order.refundedAt) and if (order.paidAt && order.shippedTrackingNumber). The type permits states that cannot exist: an order with both refundedAt and shippedTrackingNumber. The compiler will not stop you from writing code that handles that impossible state, and it will not stop you from forgetting a real one.
A discriminated union flips the model. You name the states and the compiler walks you through them.
type Order =
| { status: "pending" }
| { status: "paid"; paidAt: Date }
| { status: "shipped"; paidAt: Date; trackingNumber: string }
| {
status: "refunded";
paidAt: Date;
refundedAt: Date;
refundReason: string;
};
function describe(order: Order): string {
if (order.status === "shipped") {
return `In transit: ${order.trackingNumber}`;
}
if (order.status === "refunded") {
return `Refunded ${order.refundedAt.toISOString()}: ${order.refundReason}`;
}
if (order.status === "paid") {
return `Paid ${order.paidAt.toISOString()}`;
}
return "Pending payment";
}
What this deletes: if (order.refundedAt) checks. Optional-chaining cascades like order.refund?.reason ?? "". Whole categories of "but the order was shipped and refunded?" production tickets. The narrowing is automatic the moment you compare status to a literal.
The trick in real code is that the wire format coming off your message bus or REST endpoint usually doesn't already look like this. You parse it into the discriminated shape at the boundary (Zod, Valibot, ArkType, or hand-rolled) and from that point inward, the union is what every function takes. The cast at the edge becomes a parser. Every consumer downstream loses its guesses.
2. satisfies instead of as
as is the keyword that breaks every other technique on this list. satisfies is the one that should replace it almost everywhere.
The rule of thumb: as says "trust me, this is a T." satisfies says "check that this fits T, but keep the narrower type the literal already had." One overrides the type checker, the other recruits it.
type RouteConfig = Record<string, { method: "GET" | "POST"; auth: boolean }>;
// Anti-pattern: the cast erases the keys
const routes = {
listUsers: { method: "GET", auth: true },
createUser: { method: "POST", auth: true },
health: { method: "GET", auth: false },
} as RouteConfig;
routes.listUsers.method;
// type: "GET" | "POST" — the literal "GET" was thrown away
routes.helth;
// no error — the cast widened the keys to string
const routes = {
listUsers: { method: "GET", auth: true },
createUser: { method: "POST", auth: true },
health: { method: "GET", auth: false },
} satisfies RouteConfig;
routes.listUsers.method;
// type: "GET" — exact literal, preserved
routes.helth;
// Property 'helth' does not exist on type ...
What satisfies deletes: typo bugs in config objects that survive code review because the cast hid them. Const-assertion gymnastics where you write as const to get literal types, then as RouteConfig to validate the shape, then a giant comment explaining why the order matters. The single-keyword swap solves both problems in one pass.
as still has a job. DOM type assertions where you genuinely know more than the compiler (document.getElementById("x") as HTMLInputElement) and bridging across unknown after a parse are both legitimate. The daily-driver use of as to "tell TypeScript the shape" is the wrong reach. If you can write satisfies, write satisfies.
3. The in operator narrows without a runtime hit
Sometimes you're holding a value whose type is a union of two object shapes, and you need to figure out which one you have. The TypeScript playground answer is to add a discriminant. The real-world answer is that you got the value from a third-party library that doesn't have one.
type Success = { data: User };
type Failure = { error: string; retryAfter?: number };
type Response = Success | Failure;
function handle(r: Response) {
if ("error" in r) {
// r is Failure here
console.warn(r.error);
return;
}
// r is Success here
return r.data;
}
That's it. No typeof, no instanceof, no helper. The in operator is a JavaScript primitive that compiles to itself, and TypeScript narrows on the result. The check costs one property lookup at runtime and zero bytes of helper code.
The pattern earns its keep with library responses you can't change. Stripe, GitHub's REST client, AWS SDK error variants — they all return shapes where the discriminator is the presence of a field rather than a tag. in matches the way the library was already designed.
A subtlety worth knowing: "error" in r narrows on the existence of the key, not the truthiness of the value. If the library returns { error: undefined, data: ... } for the success case, in will not save you. Check the library's actual output before trusting the narrowing.
4. User-defined type guards when the predicate isn't trivial
When the discriminator isn't a single property, when "is this a paid order" depends on three fields and a date check, extract the predicate into a function and let it return a type predicate.
type Order =
| { status: "pending" }
| { status: "paid"; paidAt: Date; total: number };
function isPaid(o: Order): o is Extract<Order, { status: "paid" }> {
return o.status === "paid" && o.paidAt instanceof Date && o.total > 0;
}
function chargeFee(o: Order) {
if (isPaid(o)) {
return o.total * 0.029 + 0.30; // o is the paid variant
}
return 0;
}
The o is X return type is what makes the narrowing portable. Anywhere you call isPaid(o), the compiler narrows the union. Without the predicate annotation, isPaid would still type-check, but its callers would not benefit. They'd see a boolean and the union would stay wide.
Two things to know. Type predicates trust you: the compiler does not check that your predicate body actually proves the type. If you return true for a non-paid order, the predicate lies and the runtime crashes. Treat the predicate body the same way you'd treat a parser. Write it carefully, test it, don't shortcut.
Also, in TypeScript 5.5 the team shipped inferred type predicates: in many cases you can write (o) => o.status === "paid" inside a .filter and the compiler will infer the predicate for you. The explicit guard above is still the right tool when the body is non-trivial; the inferred form is for the one-liners.
5. Assertion functions for invariants you want to crash on
A type guard returns a boolean. An assertion function throws. You want assertion functions when "this thing is malformed" is a programmer error, not a control-flow branch.
function assertNonNull<T>(
value: T | null | undefined,
msg: string,
): asserts value is T {
if (value == null) {
throw new Error(`Invariant: ${msg}`);
}
}
function processWebhook(req: { body: Record<string, unknown> }) {
const userId = req.body.userId;
assertNonNull(userId, "webhook body missing userId");
// userId is now `unknown` (still need parsing); the !-cast is gone
}
The asserts value is T return signature is what makes it work. After the call, the compiler treats value as narrowed for the rest of the scope. No if (!value) return ladder. No value!.foo.bar after a check three lines up. The invariant lives in one place and the rest of the function reads cleanly.
There's a TypeScript-specific gotcha: assertion functions and arrow functions don't mix. const assertNonNull = <T>(...): asserts value is T => { ... } will not compile. The assertion has to be on a function declaration. This has been a known design limitation for years.
The other reach for this is environmental invariants. Configuration loaded from process.env is string | undefined and stays that way until you assert it.
function requiredEnv(name: string): string {
const value = process.env[name];
if (value === undefined) {
throw new Error(`Required env var ${name} is not set`);
}
return value;
}
const dbUrl = requiredEnv("DATABASE_URL"); // string, no `!`
Same shape, different return: returning the value instead of asserting in place is the pattern that scales when you want the "where did this come from" line in your stack trace to be specific.
6. never exhaustiveness as a compile-time test
The point of a discriminated union is that you can teach the compiler to break the build when someone adds a new case and forgets to handle it.
type Event =
| { kind: "user.created"; userId: string }
| { kind: "user.deleted"; userId: string }
| { kind: "user.suspended"; userId: string; reason: string };
function audit(e: Event): string {
switch (e.kind) {
case "user.created":
return `created ${e.userId}`;
case "user.deleted":
return `deleted ${e.userId}`;
case "user.suspended":
return `suspended ${e.userId}: ${e.reason}`;
default:
return assertNever(e);
}
}
function assertNever(x: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(x)}`);
}
When someone adds { kind: "user.reactivated"; userId: string } to Event six months from now, the default case starts seeing a type that is not never, and assertNever(e) fails to compile. The CI build breaks. The new case is not allowed to ship until somebody decides what audit does with it.
This is the pattern that earns its rent. It scales with the team, not with the lines of code. The author of audit doesn't have to remember to update it when the union changes: the compiler will not let anybody else forget either. Pair this with a discriminated union from pattern 1 and a parser at the boundary, and a class of "we added a new event type and one consumer dropped it on the floor" bugs disappears.
A note on ts-pattern: the library by Gabriel Vergnaud (currently at 5.9.0, with millions of weekly downloads on npm) gives you exhaustive match() expressions with the same compile-time guarantee, plus pattern matching on nested fields and arrays. If your discriminants are nested or your unions are wide, the switch+assertNever ceremony gets old fast and the library is the right reach. The plain switch above is the version that ships with no dependencies.
The 500 on Tuesday becomes a build break on Monday, and you ship something else that week.
If this was useful
This is the narrowing chapter from TypeScript Essentials, condensed into one post. The book starts from "you already write JavaScript" and walks the type system end-to-end across Node, Bun, Deno, and the browser, with the daily-driver patterns spelled out and the pitfalls labelled. If your team is the one drowning in as-casts across half the codebase, that's the entry point.
The full collection (The TypeScript Library) is five books that share a vocabulary. Books 1 and 2 are the core path. Books 3 and 4 are bridges if your team comes from the JVM or PHP world. Book 5 is for whoever is owning the build, the monorepo, and the ESM/CJS dual-publish problem.
- TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser: amazon.com/dp/B0GZB7QRW3 — the entry point. Types, narrowing, modules, async, daily-driver tooling.
-
The TypeScript Type System — From Generics to DSL-Level Types: amazon.com/dp/B0GZB86QYW — generics, mapped and conditional types,
infer, template literals, branded types. - Kotlin and Java to TypeScript — A Bridge for JVM Developers: amazon.com/dp/B0GZB2333H — variance, null safety, sealed classes to unions, coroutines to async/await.
- PHP to TypeScript — A Bridge for Modern PHP 8+ Developers: amazon.com/dp/B0GZBD5HMF — sync to async paradigm, generics, discriminated unions for PHP-shaped brains.
- TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes: amazon.com/dp/B0GZB7F471 — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.

Top comments (0)