Chapter 8 of 24 · TypeScript for Beginners — Complete Free Tutorial
Narrowing and Type Guards
Chapter 7 of the TypeScript for Beginners series left you with unions the compiler refuses to let you touch: on string | number you may only call members both branches share. Narrowing is how you prove which branch a value belongs to, and every check you write — typeof, ===, in, a custom guard — teaches the compiler something new. This is the heart of the 24-chapter series: get narrowing right and the rest of TypeScript falls into place.
Why narrowing exists
The compiler sees a declaration, not the future: at the top of a function a parameter really could be either branch, so it assumes the worst and only lets you touch members both branches share. Then you write a test, and every branch the test rules out disappears from the value's type. In function double(value: string | number), the line if (typeof value === 'string') return value.repeat(2); proves that value is a string inside the if, and after the early return only the number branch remains, so value * 2 is legal. You never annotated the narrowed types — control flow did the work.
typeof narrowing
typeof is the everyday guard. It returns one of eight strings, and matching one of them against a union member narrows to that member:
| Test | Narrows to | Watch out for |
|---|---|---|
typeof v === 'string' | string | — |
typeof v === 'number' | number | NaN counts as a number |
typeof v === 'undefined' | undefined | the usual way to remove absence |
typeof v === 'object' | objects and arrays | null reports 'object' too |
typeof v === 'function' | functions | an array is never 'array' |
It is also the only reliable first step when the incoming type is unknown: the test never throws whatever the value holds, and afterwards value.toUpperCase() is legal without a cast. Two quirks to memorise: typeof null is 'object' (a bug from 1995 that cannot be fixed), so test value !== null separately, and arrays report 'object' too — use Array.isArray(value) whenever you need to detect a list.
Truthiness and equality narrowing
An if that does not name a type still narrows. A truthiness test removes everything falsy — false, 0, -0, 0n, '', null, undefined and NaN — from the union, and each === then removes one concrete possibility from the branch you are not in. A switch applies the same rule one case at a time:
function greet(name: 'guest' | 'admin' | '' | null): string {
if (!name) return 'hello, stranger'; // '' and null are falsy
if (name === 'guest') return 'hello, guest';
return `hello, ${name}`; // only 'admin' remains
}
console.log(greet(null));
console.log(greet(''));
console.log(greet('guest'));
console.log(greet('admin'));
Expected output:
hello, stranger
hello, stranger
hello, guest
hello, admin
Handy details: name == null (two equals) removes both null and undefined in one test, comparing two union variables with === narrows both sides at once, and !== narrows the else-branch. The same rules come from truthiness and coercion you already know from JavaScript — TypeScript simply records what each test proves.
in and instanceof narrowing
Object unions are usually told apart by a property. The in operator proves whether a member exists, which narrows to the shapes that declare it: with type Fish = { name: string; swims: true } and type Bird = { name: string; flies: true }, the check if ('swims' in animal) makes animal a Fish inside the branch and a Bird after it — the switch you would have written by hand, with the compiler keeping score.
instanceof is the sibling test for class instances: if (value instanceof Date) narrows to Date and gives you getTime(), getHours() and friends. It works by checking the prototype chain, so it is useless against data parsed from JSON — a plain object is never instanceof Date, no matter what its fields look like. For that situation, check typeof value.createdAt === 'string' instead, or use a guard.
Custom type guards
When a condition is too clever for the built-in checks, write the test yourself and describe its conclusion in the return type. The syntax value is SomeType turns an ordinary function into a type guard:
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.length > 0;
}
function firstWord(value: unknown): string {
if (isNonEmptyString(value)) return value.split(' ')[0];
return 'n/a';
}
console.log(firstWord('type script'));
console.log(firstWord(7));
Expected output:
type
n/a
Three rules keep guards honest. The named type must be assignable to the parameter's type, the function must return a boolean, and the implementation must actually be correct — TypeScript believes your predicate blindly, so a guard that returns true for the wrong value simply moves the crash somewhere less convenient. The assert-form function assertString(value: unknown): asserts value is string does the same job in reverse: it throws when the check fails, and after the call the variable is a string for the rest of the function. Prefer the plain predicate for reusable code and save the assert form for the top of a function that must validate its inputs.
Discriminated unions
The most useful pattern in the language combines everything above. Give every variant of a union one tiny literal field — usually kind or status — and switch on it. The field is called the discriminant, and each variant carries only the fields that belong to it:
type RequestState =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'success'; data: string[] };
function render(state: RequestState): string {
switch (state.status) {
case 'loading': return 'Loading…';
case 'error': return `Error: ${state.message}`;
case 'success': return `${state.data.length} items`;
}
}
console.log(render({ status: 'loading' }));
console.log(render({ status: 'error', message: 'offline' }));
console.log(render({ status: 'success', data: ['a', 'b', 'c'] }));
Expected output:
Loading…
Error: offline
3 items
Inside case 'error' the compiler knows the state is exactly the error variant, so state.message is safe and state.data does not exist. This is the same idea behind the status prop you may have used in conditional rendering — typed, and with no chance of rendering a spinner for a finished request.
Exhaustive checking with never
A switch with every case covered is a promise: no variant can slip through. TypeScript can hold you to it. After the final case, the value's type has been narrowed to never — a type with no values at all — so you can prove completeness with one tiny function:
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number };
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle': return Math.PI * shape.radius ** 2;
case 'rectangle': return shape.width * shape.height;
default: return assertNever(shape);
}
}
console.log(area({ kind: 'circle', radius: 1 }).toFixed(2));
console.log(area({ kind: 'rectangle', width: 3, height: 4 }));
Expected output:
3.14
12
The default branch compiles because shape really is never there. Now add a third variant, { kind: 'triangle'; base: number; height: number }, without touching area: the case you forgot is no longer never, and the compiler reports Argument of type '{ kind: "triangle"; base: number; height: number; }' is not assignable to parameter of type 'never' right where the fix belongs. Chapter 10 takes a proper look at never.
Common mistakes
- Trusting truthiness with
0or''— a zero price or an empty search string is falsy and will be dropped; compare explicitly when zero is a real answer. - Waiting for
typeofto say'array'— it never does; arrays are objects, so useArray.isArray(value). - Using
instanceofon parsed data — JSON gives you plain objects, sovalue instanceof Dateis always false; check the shape of the fields instead. - Writing guards that lie — the compiler trusts
value is stringcompletely, so a wrong predicate converts a compile-time error into a runtime crash. - Leaving a union half-handled — a missing case shows up as
undefinedat runtime; add anassertNeverdefault so the compiler catches it.
Practice checklist
- Rewrite the
formatIdfunction from chapter 7 using narrowing and confirm both branches type-check. - Take a
string | number | nullparameter and handle all three withtypeofand an explicit null check. - Write one guard of your own (
isNonEmptyArrayorisUser) and use it to read a property safely. - Model a three-variant status union and render it with a
switch. - Add an
assertNeverdefault to that switch, then add a fourth variant and read the error you get.
What's next?
Narrowing works on fixed values — next you learn how to describe those values precisely and package them as enums: Literal Types and Enums.
Frequently Asked Questions
TypeScript is a strict superset of JavaScript that adds a static type system. You annotate the promises your data makes, the compiler checks every one before the code runs, then erases the types and emits ordinary JavaScript. You catch wrong arguments, typoed properties and forgotten null checks while you are still typing, and refactoring plus editor autocomplete both improve sharply.
You should. This series assumes you can already read and write JavaScript: functions, arrays and objects, closures, modules and async/await. TypeScript audits that knowledge rather than replacing it, and types cannot rescue code you do not understand. If those fundamentals feel shaky, work through the JavaScript for Beginners series on this site first, and this course will go much faster.
No. TypeScript is compiled, not interpreted: tsc parses your file, type-checks it, then erases every annotation and writes ordinary JavaScript. The browser and Node.js only ever run that emitted .js, so runtime behaviour, performance and debugging are unchanged. It also never validates API data at runtime, so the series teaches you to distrust every boundary that leaves the compiler.
If you already know JavaScript, TypeScript is a modest step rather than a new language: the syntax is the same plus a colon. Most beginners read their first red squiggle within days and feel comfortable in two to four weeks of regular practice. The curve steepens later around generics and advanced types, which is where this 24-chapter series deliberately spends its second half.
Both name the same object shapes, so either works for a simple contract. The differences decide the rest: only interfaces merge when declared twice, while only type aliases can express unions, primitives, tuples and mapped types. The series default is interface for props and exported shapes other files consume, and type for everything else. Pick one style per project and stay consistent.
strict: true is a bundle of safety switches, not a single behaviour. It turns on noImplicitAny, so every parameter must be inferrable, strictNullChecks, so null and undefined are their own types you must narrow, strictFunctionTypes, noImplicitThis, strictPropertyInitialization, and useUnknownInCatchVariables, which types catch values as unknown. Every example in this series compiles with it on.
any accepts anything and switches the compiler off, so one any spreads through inference into everything derived from it and you are back to dynamic typing with a .ts extension. unknown accepts anything too, but forbids property access until you narrow it with a typeof or instanceof check. The series pushes you toward unknown because it keeps the check you installed TypeScript to get.
Yes to both. Vite understands TypeScript natively: scaffold the vanilla-ts or react-ts template and a tsconfig.json is waiting for you, with moduleResolution set for the bundler. JSX belongs in .tsx files, props get an interface, and hooks infer or accept explicit type arguments. The series covers typed React in depth, from props, state and events to refs, context and fetch effects.
Enums still compile and work fine in TypeScript 5.x, but they are no longer the default recommendation. A union of string literals such as "light" | "dark" needs no runtime object, matches JSON directly and plays well with single-file transpilers like Vite's esbuild. Keep enum for existing codebases that already use it, avoid const enum, and prefer literal unions in new code.
Adopt it incrementally instead of rewriting. Install typescript, add a tsconfig.json with allowJs and noEmit so .js and .ts coexist while your bundler keeps building, then check individual files with a // @ts-check comment. Rename leaf modules to .ts one at a time, fix implicit any errors first, and gate every pull request with tsc --noEmit so untyped code cannot sneak back in.