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:

TestNarrows toWatch out for
typeof v === 'string'string—
typeof v === 'number'numberNaN counts as a number
typeof v === 'undefined'undefinedthe usual way to remove absence
typeof v === 'object'objects and arraysnull reports 'object' too
typeof v === 'function'functionsan 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 0 or '' — a zero price or an empty search string is falsy and will be dropped; compare explicitly when zero is a real answer.
  • Waiting for typeof to say 'array' — it never does; arrays are objects, so use Array.isArray(value).
  • Using instanceof on parsed data — JSON gives you plain objects, so value instanceof Date is always false; check the shape of the fields instead.
  • Writing guards that lie — the compiler trusts value is string completely, so a wrong predicate converts a compile-time error into a runtime crash.
  • Leaving a union half-handled — a missing case shows up as undefined at runtime; add an assertNever default so the compiler catches it.

Practice checklist

  1. Rewrite the formatId function from chapter 7 using narrowing and confirm both branches type-check.
  2. Take a string | number | null parameter and handle all three with typeof and an explicit null check.
  3. Write one guard of your own (isNonEmptyArray or isUser) and use it to read a property safely.
  4. Model a three-variant status union and render it with a switch.
  5. Add an assertNever default 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.

Quiz: Narrowing and Type Guards

5 questions · 70% to pass

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.