Narrowing and type assertions
Reading union-typed values safely in TypeScript with control-flow narrowing instead of the as escape hatch.
The previous lesson left a problem open: a field that lives only on User can’t be read off a User | Guest, because the compiler doesn’t know which variant you hold. The fix was a runtime check, 'email' in value. This lesson covers the full set of those checks and the rule behind them.
Skipping the check looks tempting:
const getEmail = (value: User | Guest): string => { return (value as User).email;};The as User tells the compiler “trust me, this is a User.” It compiles and ships. Months later a Guest flows through, value.email is undefined, and the next line that reads it crashes, unflagged.
A type assertion changes nothing at runtime; it only stops the compiler from checking. Do the opposite: write a check the language can read, and let it carry the resulting type into the block.
How TypeScript narrows types from runtime checks
Section titled “How TypeScript narrows types from runtime checks”TypeScript reads a function body top to bottom, 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'), it treats x as number in the if branch and as the remaining union members in the else. The check runs at runtime, the refinement happens at compile time, and the feature tying them together has a name: control-flow narrowing . The checks that drive it are typeof, in, and === on a discriminant; the lesson works through each.
One rule captures the whole lesson:
Narrow with a runtime check the language tracks. Never assert past a union without one of three named triggers.
A narrowing check is sound: the compiler reads code that actually runs and carries forward a fact it has seen hold. An assertion gives it nothing to observe, so it takes the developer’s word instead. Narrowing is the default; the assertion is an escape hatch for the three cases named later.
The narrowing forms and when to use each
Section titled “The narrowing forms and when to use each”TypeScript has six core narrowing forms. The set is small, so at each union read the question is “which form fits?” not “should I cast?”
const formatAmount = (amount: string | number): string => { if (typeof amount === 'number') { return amount.toFixed(2); } return amount.trim();};typeof for mixed primitives. Use it when the union mixes primitive types; the matched branch narrows to that primitive, the else to the rest.
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 when the union is a finite set of literals; the branch narrows to the literal you tested. The discriminated-union switch below runs on this same mechanism, since a discriminant is just a shared literal-typed field.
const getEmail = (value: User | Guest): string => { if ('email' in value) { return value.email; } return 'no-reply@example.com';};in for shape unions. Use it, as the previous lesson did for User | Guest, when object variants share no discriminant but one owns a field the others lack; the branch narrows to that variant.
try { await saveInvoice(input);} catch (error) { if (error instanceof ValidationError) { return showFieldErrors(error.fields); } throw error;}instanceof for class branches. Use it to branch on which class produced a value; the check reads the prototype chain and narrows to that class. The usual case is a catch block, whose binding is unknown. One caveat: instanceof is unreliable across realm boundaries like an iframe or worker, where each realm has its own Error constructor.
const normalize = (input: string | string[]): string[] => { if (Array.isArray(input)) { return input; } return [input];};Array.isArray for T | T[] unions. Use it when the union is a value or a list of it. It needs its own form because typeof reports both arrays and plain objects as 'object'.
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 shapes share a literal-typed field, as in the previous lesson’s FetchResult<T>, switching on it narrows each case to its variant. That field is the discriminant . The default branch holds the exhaustiveness check, the assertNever pattern the next chapter builds, which catches a forgotten variant when the union grows.
A seventh form is worth recognizing: the custom type predicate. When a check is too involved to inline at every use site, or needs reuse across modules, 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. It is only as honest as its body: a predicate that returns true on a non-User slips through, because the compiler trusts the signature, not the body. The next chapter covers the assertion-function form asserts value is T.
Where a narrow holds
Section titled “Where a narrow holds”A narrow holds inside the block where the check fired, and any assignment that could change the type cancels it.
This bites when a callback captures a narrowed value, because the variable could be reassigned between the check and the moment the callback runs.
const handle = (value: User | Guest) => { if ('email' in value) { const user = value; queueMicrotask(() => sendEmail(user.email)); }};The fix is the line after the check: const user = value. It captures the narrow while it holds, and the type system reads user from then on. Because user can’t be reassigned, a later assignment to value can’t reach through it.
The three legitimate triggers for as
Section titled “The three legitimate triggers for as”as has exactly three legitimate triggers; anything outside them is a signal to refactor. The file below uses each once.
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 validator reads an unknown at the boundary, checks it at runtime, and returns a typed value. The assertion lives inside the parser’s return type, so your own code writes no as.
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. You just stored a User under user.id and read it straight back, but cache.get returns User | undefined because the Map API must allow for misses. Before asserting, ask whether a refactor removes the gap; often returning the value you just set does. When it can’t, the assertion is 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. querySelector returns Element | null because it can’t infer a tag from your selector, but you can: it says “button,” and you wrote the markup. For a tightly scoped one-shot the assertion is fine; if the value flows further, use instanceof HTMLButtonElement, a runtime check the compiler reads.
as unknown as T is a smell. Stacking two assertions through unknown fully silences the type system. Occasionally that’s right (a test fixture, a third-party boundary with no honest signatures), but usually it’s a signal to refactor.
Type assertions don’t validate at runtime. value as User compiles and changes nothing at runtime, so the next line that reads a User-only field on a Guest crashes. When the data may not match the type, narrow, don’t assert.
! is as for null and undefined
Section titled “! is as for null and undefined”The non-null assertion ! tells the compiler a value isn’t null or undefined, with no runtime check.
Its most common honest use:
const ids = ['a', 'b', 'c'];const target = ids.find((id) => id === 'b')!;find returns T | undefined, but you wrote the array and the predicate, so you hold proof the type system can’t track.
The alternative names the failure:
const target = ids.find((id) => id === 'b') ?? throwError('missing id b');?? throwError(...) gives the production error a string a log scanner can grep for. Reach for ! in a one-shot script, a test fixture, or where the line carries the proof itself; reach for ?? throwError(...) when the failure could reach production.
Index access works the same way. Under noUncheckedIndexedAccess, arr[0] is T | undefined even on a non-empty array, so ! is acceptable under the same three triggers as as. Otherwise narrow with if (arr.length > 0) and capture the value, or keep the | undefined and handle the miss.
Narrowing nullable values
Section titled “Narrowing nullable values”Both ? (field may be absent) and | undefined (present but undefined) narrow with the forms you’ve seen, with one gotcha.
const greet = (user: User | null): string => { if (user) { return `Hello, ${user.name}`; } return 'Hello, guest';};Truthy check. A bare if (user) is correct for User | null: the object is truthy, null is falsy. The gotcha is broader. A truthy check on string | number | null | undefined also excludes 0, '', and false, which you usually want to keep. There, use != null: since null == undefined, one comparison catches both, and it’s a rare legitimate use of double-equals.
const name = user?.name ?? 'guest';?? for default and continue. Not a narrow, but the right reach to supply a default and continue in one expression. ?? triggers on null and undefined only, never on 0, '', or false. Use it when the value flows straight into a use; use an explicit check when the branches 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 reading .email. But it doesn’t narrow user, so its type outside the expression is unchanged. When several reads follow, narrow once with an early return, then read directly.
Practice: rewrite an assertion-heavy function
Section titled “Practice: rewrite an assertion-heavy function”The function below carries two as assertions, each removed a different way.
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?”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 for code review: 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.