Narrow, don't assert
How to read union-typed values safely in TypeScript by leaning on control-flow narrowing instead of reaching for the as escape hatch.
The previous lesson left you with a working knowledge of unions and one unresolved problem: reading a field on a User | Guest only works if the field exists on both variants. If you reach for a field that lives only on User, the compiler stops you. The fix you saw at the end was a runtime check, 'email' in value. This lesson covers the full set of those checks and the rule that ties them together. It also points out the three places where the right move is not a check but a type assertion, and explains why every other use of as tends to surface as a bug months later.
We’ll start with the bug that an assertion creates.
const getEmail = (value: User | Guest): string => { return (value as User).email;};The as User tells the compiler “trust me, this is a User.” Compilation succeeds and the function ships. Months later a Guest flows through, value.email evaluates to undefined, and the next line that touches it crashes, whether that line is email.toLowerCase(), a log statement, or a database write. The compiler never flagged anything, because the assertion told it not to.
The cause is straightforward. A type assertion has no runtime effect: the value is exactly what it was before the as. The compiler stops checking, while the runtime keeps doing what it always did. Asserting past a union means borrowing trust the type system would otherwise have refused to give.
The habit you want instead is the opposite: write a runtime check the language can read, and let the compiler carry the resulting type into the block. Every form the last lesson hinted at, typeof, in, and === on a discriminant, is one of those checks. This lesson lays out the full set, names the three places where an assertion is still the right call, and leaves you with a rule short enough to remember at code review.
How TypeScript tracks runtime checks
Section titled “How TypeScript tracks runtime checks”TypeScript reads your function body the way you read it, top to bottom and branch by branch. When it meets a runtime check on a value, it refines that value’s type along each path. Inside if (typeof x === 'number'), the compiler knows x is number in the if branch and “not number” in the else. The check runs at runtime, the type refinement happens at compile time, and the language ties the two together.
That connection has a name: control-flow narrowing . It’s the central idea behind everything else in this lesson. One rule captures it, and the rest of the lesson follows from that rule:
Narrow with a runtime check the language tracks. Never assert past a union without one of three named triggers.
The two halves of that sentence map to the two halves of the lesson. A narrowing check is sound, because the compiler reads code that actually runs and carries forward a fact it has seen hold. An assertion gives it nothing to observe, so the compiler simply takes the developer’s word for it. Narrowing is the default; the assertion is an escape hatch you reach for only under specific conditions.
The narrowing surface
Section titled “The narrowing surface”TypeScript supports six core narrowing forms. The list is short and complete, so once you know it, the question at every union read becomes “which of these fits this case?” rather than “should I just cast?”
Each form below comes with the situation that calls for it and the type the value narrows to after the check.
const formatAmount = (amount: string | number): string => { if (typeof amount === 'number') { return amount.toFixed(2); } return amount.trim();};typeof for mixed primitives. Use typeof when the union mixes primitive types. The seven possible typeof results cover every JavaScript primitive, so you can test for any of them. Inside the if branch the value narrows to the primitive you matched, and the else branch carries the remaining union members.
type Status = 'draft' | 'sent' | 'paid';
const canEdit = (status: Status): boolean => { if (status === 'draft') { return true; } return false;};Equality on a literal union. Use === against a literal value when the union is a finite set of literals. Inside the if branch the value narrows to the single literal you tested against. This is the same mechanism that powers the discriminated-union switch a few tabs down, because a discriminant is just a literal-typed field present on every variant of a union.
const getEmail = (value: User | Guest): string => { if ('email' in value) { return value.email; } return 'no-reply@example.com';};in for shape unions. Use in when the union is a set of object shapes with no common discriminant field and one variant carries a field the others don’t. This is the form the previous lesson reached for in the User | Guest example. Inside the if branch the value narrows to the variant that owns the named field.
try { await saveInvoice(input);} catch (error) { if (error instanceof ValidationError) { return showFieldErrors(error.fields); } throw error;}instanceof for class branches. Use instanceof when the union includes class instances and you want to branch on which class produced the value. The usual case is a catch block: its binding is unknown by default, and instanceof Error is the first narrow on the way to a typed handler. The check reads the prototype chain at runtime, and the compiler narrows the value to that class.
One caveat: instanceof is unreliable across realm boundaries such as an iframe, a worker, or a separate JS context, because each realm has its own Error constructor. The full story lives in the chapter on errors.
const normalize = (input: string | string[]): string[] => { if (Array.isArray(input)) { return input; } return [input];};Array.isArray for T | T[] unions. Use Array.isArray when the union is a single value or a list of that value. It gets its own form because typeof reports both an array and a plain object as 'object', so the primitive check can’t tell them apart. Array.isArray is the dedicated check for this case.
type FetchResult<T> = | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; error: Error };
const render = <T>(r: FetchResult<T>): string => { switch (r.status) { case 'loading': return 'Loading...'; case 'success': return JSON.stringify(r.data); case 'error': return r.error.message; }};switch on a discriminant. When a union of shapes shares a literal-typed field, as in last lesson’s FetchResult<T>, a switch on that field lets every case carry the full narrowed type. The status field is the discriminant , and inside each case block the value narrows to the variant that owns that literal.
The default branch is where the exhaustiveness check lives, the pattern that catches a forgotten variant when the union grows. That pattern is named assertNever, and the next chapter covers it. For now we stop at making sure every case narrows correctly.
There’s a seventh form you’ll meet often enough to recognize: the custom type predicate. When a check is too involved to inline at every use site, or when the same check needs to be reused across modules, you can package it as a function whose return type tells the compiler “I have verified this fact.”
function isUser(value: unknown): value is User { return ( typeof value === 'object' && value !== null && 'email' in value && typeof value.email === 'string' );}The value is User return type is a type predicate . When isUser(x) returns true, the compiler narrows x to User at the call site. The thing to watch is that the predicate is only as honest as its body. If your isUser returns true on a value that isn’t actually a User, the type system has no way to catch it, because it trusts the signature rather than reading the body. The next chapter goes deeper, including the assertion-function form asserts value is T. For now we just introduce the shape so you recognize it when you read it.
The discriminated-union switch is worth a closer look, because it leads into the exhaustiveness check the next chapter covers. Once you’ve built the switch, the next question is how the compiler warns you when someone adds a new variant and the switch forgets to handle it. That’s the never-typed assertNever pattern, which the next chapter builds in full.
The narrowing scope rule
Section titled “The narrowing scope rule”A narrow is not a property of the value. It’s a property of the block where the check ran. This distinction is what separates a working refactor from a closure bug that surfaces only in production.
A narrow holds inside the block where the check fired, and any assignment that could change the type cancels it.
This most often causes trouble when a callback captures a narrowed value, because the variable could be reassigned between the check and the moment the callback fires.
const handle = (value: User | Guest) => { if ('email' in value) { const user = value; queueMicrotask(() => sendEmail(user.email)); }};The fix is the line right after the check: const user = value. The const binding captures the narrow at the moment it holds, and from then on the type system reads user rather than value. Because user can never be reassigned, a later reassignment to value in the same block can’t reach back through it.
The three legitimate triggers for as
Section titled “The three legitimate triggers for as”This is the other half of the lesson. The rule isn’t “use as sparingly.” It’s that as has exactly three legitimate triggers, and anything outside those three is a signal to refactor. Walking through them once gives you the checklist you’ll want at code review.
Read the file below. It has three separate uses of as, each acceptable for a different and specific reason. The annotated walkthrough explains each one.
import { z } from 'zod';
const userSchema = z.object({ id: z.string(), email: z.string().email(),});
type User = z.infer<typeof userSchema>;
const parseUser = (raw: unknown): User => { return userSchema.parse(raw);};
const cache = new Map<string, User>();
const remember = (user: User): User => { cache.set(user.id, user); return cache.get(user.id) as User;};
const wireUpSubmit = () => { const submit = document.querySelector('button[type="submit"]') as HTMLButtonElement; submit.addEventListener('click', () => parseUser({}));};Boundary parse-then-trust. A Zod schema, or any validator, reads an unknown at the boundary, runs the structural check at runtime, and returns a typed value. The assertion lives inside the parser’s return type, so your own code never writes as. One thing to hold onto ahead of the Zod chapter: a typed value at a boundary comes from a parse, not from a cast. This is the only one of the three triggers where you write no as at all.
import { z } from 'zod';
const userSchema = z.object({ id: z.string(), email: z.string().email(),});
type User = z.infer<typeof userSchema>;
const parseUser = (raw: unknown): User => { return userSchema.parse(raw);};
const cache = new Map<string, User>();
const remember = (user: User): User => { cache.set(user.id, user); return cache.get(user.id) as User;};
const wireUpSubmit = () => { const submit = document.querySelector('button[type="submit"]') as HTMLButtonElement; submit.addEventListener('click', () => parseUser({}));};TypeScript can’t see what you can prove. The line before stored a User under user.id in the cache, and the line after reads that same key back. You know the key is present and the value is a User, and so does the runtime. TypeScript can’t track that flow through a Map: cache.get(key) returns User | undefined because the API has to account for misses in the general case. The assertion records a fact the developer can prove but the type system can’t. The question to ask at code review is whether a refactor would remove the need. Often it would: return the value you just set and skip the round-trip. When the assertion survives that question, it’s acceptable.
import { z } from 'zod';
const userSchema = z.object({ id: z.string(), email: z.string().email(),});
type User = z.infer<typeof userSchema>;
const parseUser = (raw: unknown): User => { return userSchema.parse(raw);};
const cache = new Map<string, User>();
const remember = (user: User): User => { cache.set(user.id, user); return cache.get(user.id) as User;};
const wireUpSubmit = () => { const submit = document.querySelector('button[type="submit"]') as HTMLButtonElement; submit.addEventListener('click', () => parseUser({}));};The DOM and third-party type gaps. document.querySelector('button[type="submit"]') returns Element | null because the DOM API can’t read your selector and infer the matching tag. You can, though: the selector says “button,” and you wrote the markup. For a tightly scoped one-shot like this, the assertion is acceptable. If the value flows further than the next few lines, across a component boundary, into a hook, or into shared state, the honest alternative is instanceof HTMLButtonElement, a real runtime check the compiler can read.
Two more rules belong here. They’re short, and you’ll want them alongside the three triggers.
as unknown as T is a smell. Stacking two assertions through unknown means you’re fully silencing the type system. Occasionally that’s the right call, such as a test fixture that needs to mimic a private type, or a third-party boundary with no honest signatures. Usually it’s a signal to refactor. The form is deliberately ugly so it stands out in a review, which is the whole point.
Type assertions don’t validate at runtime. This restates the opening bug as a rule, because the rule is the part that stays with you. value as User compiles, the runtime value is unchanged, and the next line that touches a User-only field on a Guest crashes. When the data doesn’t match the type, the fix is to narrow, not to assert.
! is as with a narrower trigger
Section titled “! is as with a narrower trigger”The non-null assertion operator, !, works like as but does only one thing. It tells the compiler “this value is not null or undefined” without writing a runtime check.
Its most common legitimate use looks like this:
const ids = ['a', 'b', 'c'];const target = ids.find((id) => id === 'b')!;Here the caller has just proved the element exists through a check the type system can’t track: find returns T | undefined, but you wrote both the predicate and the array. The ! saves a line.
The honest alternative spells the failure out:
const target = ids.find((id) => id === 'b') ?? throwError('missing id b');The trade-off is clear. The ! is shorter, while ?? throwError(...) gives the production error a name a log scanner can grep for. Reach for ! when the call site is a one-shot script, a test fixture, or a local proof the line carries on its own. Reach for ?? throwError(...) when the failure could show up in production.
The same reasoning applies to the rule from the dynamic-keys lesson. Under noUncheckedIndexedAccess, arr[0] is T | undefined even on a non-empty array, because the compiler can’t read the length. The ! shortcut is acceptable under the same three triggers as as. Outside them, narrow with if (arr.length > 0) and capture, or accept the | undefined and handle the miss explicitly.
Narrowing across null and undefined
Section titled “Narrowing across null and undefined”One short section covers the nullable case. From the object shapes lesson, recall the difference between ? (the field may be absent) and | undefined (the field is present with the value undefined). The narrowing forms for nullable unions are the same ones you’ve already seen, with one gotcha worth repeating.
const greet = (user: User | null): string => { if (user) { return `Hello, ${user.name}`; } return 'Hello, guest';};Truthy check. A bare if (user) is the simplest form, and it works correctly when the type is User | null, because the object is truthy and null is falsy. The gotcha shows up in the broader case: a truthy check on string | number | null | undefined also excludes 0, '', and false, which you usually want to keep. For that case, use != null, one of the rare legitimate uses of double-equals: because null == undefined is true, the one comparison catches both. The explicit !== null && !== undefined works too.
const name = user?.name ?? 'guest';?? for default and continue. Not a narrow exactly, but the right reach when the goal is to supply a default and continue past the nullable in one expression. ?? triggers on null and undefined only, never on 0, '', or false. Reach for ?? when the value flows straight into a use, and reach for an explicit check when the two branches need to do different things.
const greetAndEmail = (user: User | null): string | null => { if (!user) return null; return `${user.name} <${user.email}>`;};?. short-circuits, it doesn’t narrow. Optional chaining short-circuits at runtime: if user is null, user?.email is undefined without ever reading .email. What it doesn’t do is narrow user for later reads, so the type of user outside the expression stays exactly as it was. When several reads follow, narrow once at the top of the block with an early return, then read directly.
Practice: rewrite an assertion-heavy function
Section titled “Practice: rewrite an assertion-heavy function”You’ve seen the six narrowing forms, the scope rule, the three assertion triggers, and the nullable case. The next step is to put them into practice. The function below is the kind of code that compiles, ships, and later breaks: two as assertions, with a different way to remove each one.
Refactor the function to use narrowing on both reads. The two @ts-expect-error directives at the bottom of the file must still trigger, which proves the unnarrowed access continues to fail, and the two ^? queries inside the narrowed blocks must resolve to the expected narrowed types.
Refactor describe so neither `as` survives. In the probes below, replace each `false` with the runtime check that narrows the value before the read — the ^? queries must resolve to the narrowed types, and the two @ts-expect-error directives must keep firing (proving the unguarded access still fails).
-
Type query at line 32 must resolve to a type containing
email: string -
Type query at line 38 must resolve to a type containing
narrowedKey: "admin" | "member"
Sort each scenario: narrow or assert?
Section titled “Sort each scenario: narrow or assert?”One closing sort locks in the habit. Every union read in your code either has a narrowing form available or sits at one of the three named assertion triggers. The exercise below lists eight scenarios; drop each one into the bucket that fits.
For each union read below, decide whether a narrowing check is available or whether the read sits at one of the three legitimate assertion triggers. Drag each item into the bucket it belongs to, then press Check.
string | number that needs to format as currency for numbersUser | Guest value where only User has an email fieldFetchResult<T> discriminated on statusparsed = userSchema.parse(payload) where payload was unknowndocument.querySelector('button[type="submit"]') call in a tightly-scoped one-shoterror value inside a catch (error) blockmap.get(id) immediately after map.set(id, user)User | null returned from a database lookup before reading .emailThe rule to carry into code review, in one line: narrow with the language, and reach for as only at the boundary, the proof gap, or the DOM seam.
External resources
Section titled “External resources”The official walkthrough of every narrowing form — typeof, truthiness, equality, in, instanceof, predicates, and exhaustiveness with never.
Matt Pocock on what `as` can and can't reach, the `as unknown as T` double-cast smell, and why the assertion is a claim the type system can't check.
The runtime mechanics of `instanceof` — prototype-chain check, plus the cross-realm pitfall (iframe, worker) named in the lesson.