TL;DR
Learn TypeScript type narrowing with typeof, instanceof, in operator, discriminated unions, and user-defined type guards.
Key concepts
- TypeScript type narrowing
- type guards TypeScript
- instanceof TypeScript
- user-defined type guards
Type Narrowing
You have a value typed as string | number | null. Before you can call .toUpperCase() on it, TypeScript needs proof that it's actually a string. That proof is called type narrowing — the process of refining a broad type into something more specific based on runtime checks. TypeScript's control flow analysis watches your conditionals and automatically tracks which types are possible at each point in your code.
The typeof Guard
The most basic form of narrowing uses JavaScript's typeof operator. TypeScript understands typeof checks and uses them to narrow primitive types.
function formatValue(value: string | number | boolean): string {
if (typeof value === "string") {
// TypeScript knows: value is string here
return value.toUpperCase();
}
if (typeof value === "number") {
// TypeScript knows: value is number here
return value.toFixed(2);
}
// TypeScript knows: value is boolean here
return value ? "Yes" : "No";
}
console.log(formatValue("hello")); // "HELLO"
console.log(formatValue(3.14159)); // "3.14"
console.log(formatValue(true)); // "Yes"
console.log(formatValue(false)); // "No"
Notice there's no else branch needed — TypeScript tracks which types have already been handled and narrows the remaining possibilities automatically. This is called control flow analysis.
Truthiness Narrowing and Nullish Checks
When a value can be null or undefined, a simple truthiness check narrows it away. This is the most common pattern you'll write in real code.
interface User {
name: string;
bio: string | null;
website?: string;
}
function renderProfile(user: User): string {
// Nullish check: bio could be null
const bioLine = user.bio !== null
? `Bio: ${user.bio}`
: "No bio provided.";
// Truthiness check: website could be undefined
const websiteLine = user.website
? `Website: ${user.website}`
: "No website.";
return [user.name, bioLine, websiteLine].join("\n");
}
const alice: User = { name: "Alice", bio: "Engineer at Acme", website: "https://alice.dev" };
const bob: User = { name: "Bob", bio: null };
console.log(renderProfile(alice));
console.log("---");
console.log(renderProfile(bob));
Predict
The parameter is string | null | undefined. The early return uses == null (loose equality, one equals sign short of ===). After that guard, value.toUpperCase() is called with no cast. Predict what the compiler narrowed value to on that line, and therefore what the first two logged calls print.
function shout(value: string | null | undefined): string {
if (value == null) {
return "(nothing)";
}
// value is narrowed here — no cast used
return value.toUpperCase();
}
console.log(shout("hi"));
console.log(shout(null));The instanceof Guard
For class instances, instanceof is the right tool. TypeScript narrows the type to the specific class inside the branch.
class NetworkError extends Error {
statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
}
}
class ValidationError extends Error {
field: string;
constructor(message: string, field: string) {
super(message);
this.field = field;
}
}
function handleError(error: NetworkError | ValidationError | Error): string {
if (error instanceof NetworkError) {
// TypeScript knows: error has statusCode
return `Network error ${error.statusCode}: ${error.message}`;
}
if (error instanceof ValidationError) {
// TypeScript knows: error has field
return `Validation failed on "${error.field}": ${error.message}`;
}
// TypeScript knows: error is base Error
return `Unexpected error: ${error.message}`;
}
console.log(handleError(new NetworkError("Not Found", 404)));
console.log(handleError(new ValidationError("Required", "email")));
console.log(handleError(new Error("Something went wrong")));
Discriminated Unions
Discriminated unions are the most powerful narrowing pattern in TypeScript. By adding a shared literal type field (often called a "tag" or "discriminant") to each member of a union, TypeScript can narrow exhaustively.
type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: string[] };
type ErrorState = { status: "error"; message: string; retryable: boolean };
type AsyncState = LoadingState | SuccessState | ErrorState;
function renderState(state: AsyncState): string {
switch (state.status) {
case "loading":
return "Loading...";
case "success":
// TypeScript knows: state.data exists
return `Loaded ${state.data.length} items: ${state.data.join(", ")}`;
case "error":
// TypeScript knows: state.message and state.retryable exist
const hint = state.retryable ? " (click to retry)" : "";
return `Error: ${state.message}${hint}`;
}
}
const loading: AsyncState = { status: "loading" };
const success: AsyncState = { status: "success", data: ["apple", "banana", "cherry"] };
const error: AsyncState = { status: "error", message: "Timeout", retryable: true };
console.log(renderState(loading));
console.log(renderState(success));
console.log(renderState(error));
The switch on state.status is exhaustive — if you add a new member to the union later and forget to handle it, TypeScript will tell you at compile time.
Debug
This should total the minutes spent across the LESSON entries only, and print 50. Instead the assert fails. Nothing about the types looks wrong at a glance — but the runner strips types and runs it anyway. Predict what total actually is before running, then fix it so it prints 50.
import assert from "node:assert";
type LessonEntry = { kind: "lesson"; title: string; minutesSpent: number };
type QuizEntry = { kind: "quiz"; title: string; score: number };
type Entry = LessonEntry | QuizEntry;
const entries: Entry[] = [
{ kind: "lesson", title: "Types", minutesSpent: 30 },
{ kind: "quiz", title: "Union quiz", score: 90 },
{ kind: "lesson", title: "Narrowing", minutesSpent: 20 },
];
// Intent: total the minutes across the lesson entries only.
function totalLessonMinutes(list: Entry[]): number {
let total = 0;
for (const entry of list) {
// The cast SILENCES the compiler instead of narrowing.
total += (entry as LessonEntry).minutesSpent;
}
return total;
}
const total = totalLessonMinutes(entries);
assert.strictEqual(total, 50, "expected 50 total lesson minutes, got " + total);
console.log("Total lesson minutes:", total);Expected output: Total lesson minutes: 50
The in Operator
When you don't control the type definitions (or can't add a discriminant), the in operator checks for property existence and narrows accordingly.
interface Circle {
radius: number;
}
interface Rectangle {
width: number;
height: number;
}
interface Triangle {
base: number;
height: number;
type: "triangle";
}
type Shape = Circle | Rectangle | Triangle;
function area(shape: Shape): number {
if ("radius" in shape) {
// TypeScript knows: shape is Circle
return Math.PI * shape.radius ** 2;
}
if ("type" in shape) {
// TypeScript knows: shape is Triangle (only Triangle has a `type` field)
return 0.5 * shape.base * shape.height;
}
// TypeScript knows: shape is Rectangle
return shape.width * shape.height;
}
console.log(area({ radius: 5 }).toFixed(2)); // "78.54"
console.log(area({ width: 4, height: 6 })); // 24
console.log(area({ base: 3, height: 8, type: "triangle" })); // 12
User-Defined Type Guards
Sometimes none of the built-in narrowing techniques are expressive enough. You can write your own type predicate — a function that returns value is SomeType — to teach TypeScript how to narrow.
interface Cat {
kind: "cat";
name: string;
lives: number;
}
interface Dog {
kind: "dog";
name: string;
breed: string;
}
type Pet = Cat | Dog;
// The return type "pet is Cat" is the type predicate
function isCat(pet: Pet): pet is Cat {
return pet.kind === "cat";
}
function describeLifespan(pet: Pet): string {
if (isCat(pet)) {
// TypeScript knows: pet is Cat — pet.lives is available
return `${pet.name} has ${pet.lives} lives.`;
}
// TypeScript knows: pet is Dog — pet.breed is available
return `${pet.name} is a ${pet.breed}.`;
}
const whiskers: Pet = { kind: "cat", name: "Whiskers", lives: 9 };
const rex: Pet = { kind: "dog", name: "Rex", breed: "German Shepherd" };
console.log(describeLifespan(whiskers));
console.log(describeLifespan(rex));
// Type guards also work in array filters
const pets: Pet[] = [whiskers, rex, { kind: "cat", name: "Luna", lives: 7 }];
const cats = pets.filter(isCat); // TypeScript infers Cat[]
console.log(`Cats: ${cats.map(c => c.name).join(", ")}`);
The pets.filter(isCat) example shows why type predicates are especially useful — the named guard states the narrowing explicitly, so .filter() hands back Cat[] rather than Pet[], and the same guard is reusable everywhere you need it. Since TypeScript 5.5 the compiler can also infer a predicate from an inline closure whose body is a recognizable narrowing check, so pets.filter(p => p.kind === "cat") gives you Cat[] too. That inference is narrow, though: annotate the parameter as (p): boolean => p.kind === "cat", or filter on something unrelated like p.name.length > 2, and you are back to Pet[] and the full union type.
Recall
Without scrolling up: the very first narrowing tool in this lesson was the typeof guard. You already met it in 04-control-flow. What does typeof value evaluate to at run time, and why does that make typeof value === 'string' usable as a type guard?
Try It Yourself
Reading about narrowing is not the same as writing a narrowing-driven report. This is a build task: a small program that reports its own pass/fail. You are given a discriminated union of three learning-entry kinds — the exact reporting shape the tracker capstone renders. Two functions are stubbed. Run it as-is and it fails immediately, naming the first stub. Implement each until every check passes and it prints All checks passed.
The two functions reuse exactly what this lesson taught: a switch on the discriminant that narrows each case to one member (with a never exhaustiveness check in the default), and a user-defined type guard driving a typed .filter(). The starter has the data, the stubs, and the checks — you write only the logic inside each function.
Build
Finish the build. Two functions are stubbed and the checks below them fail until each returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement the functions until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
import assert from "node:assert";
// The discriminated union is given. Do NOT change these.
type LessonEntry = { kind: "lesson"; title: string; minutesSpent: number };
type QuizEntry = { kind: "quiz"; title: string; score: number };
type PracticeEntry = { kind: "practice"; title: string; solved: boolean };
type Entry = LessonEntry | QuizEntry | PracticeEntry;
const entries: Entry[] = [
{ kind: "lesson", title: "Types", minutesSpent: 30 },
{ kind: "quiz", title: "Union quiz", score: 90 },
{ kind: "practice", title: "Narrowing drills", solved: true },
];
// TODO 1: render ONE report line by narrowing on the discriminant. Switch on
// entry.kind; each case reads only that member's own fields. End the default
// branch with an exhaustiveness check: `const _never: never = entry;`
// Example of one case: case "quiz": return `Quiz: ${entry.title} (scored ${entry.score})`;
// describe({ kind: "lesson", title: "Types", minutesSpent: 30 }) -> 'Lesson: Types (30m)'
// describe({ kind: "quiz", title: "Union quiz", score: 90 }) -> 'Quiz: Union quiz (scored 90)'
// describe({ kind: "practice", title: "Narrowing drills", solved: true }) -> 'Practice: Narrowing drills (solved)'
function describe(entry: Entry): string {
// your code here
return ""; // replace this
}
// TODO 2: count how many entries are quizzes, using a type guard.
// Write isQuiz so its return type is `entry is QuizEntry`, then filter with it
// so .filter narrows the array to QuizEntry[].
// countQuizzes(entries) -> 1
function isQuiz(entry: Entry): entry is QuizEntry {
// your code here
return false; // replace this
}
function countQuizzes(list: Entry[]): number {
// your code here
return 0; // replace this
}
// --- Build checks: these must all pass. Do not edit below this line. ---
assert.strictEqual(describe(entries[0]), "Lesson: Types (30m)", "TODO 1: describe should narrow and render the lesson line");
assert.strictEqual(describe(entries[1]), "Quiz: Union quiz (scored 90)", "TODO 1: describe should narrow and render the quiz line");
assert.strictEqual(describe(entries[2]), "Practice: Narrowing drills (solved)", "TODO 1: describe should narrow and render the practice line");
assert.strictEqual(countQuizzes(entries), 1, "TODO 2: countQuizzes should count only quiz entries via the type guard");
console.log("All checks passed.");
for (const entry of entries) {
console.log(describe(entry));
}
console.log("Quizzes:", countQuizzes(entries));Expected output: All checks passed.
Lesson: Types (30m)
Quiz: Union quiz (scored 90)
Practice: Narrowing drills (solved)
Quizzes: 1
Once it passes, try two variations and predict each before running:
- Invert the guard's use. In
countQuizzes, changelist.filter(isQuiz)tolist.filter((e) => !isQuiz(e))so it keeps the non-quizzes. Predict which check fails first before running. The filter now keeps the lesson and practice entries, socountQuizzes(entries)returns2, and TODO 2's check fires withAssertionError: TODO 2: countQuizzes should count only quiz entries via the type guardand2 !== 1. An instructive assert failure showing that a type guard's result depends entirely on how you use its boolean. - Add a fourth kind, skip its case. Add
type StreakEntry = { kind: "streak"; title: string; days: number };, include it in theEntryunion, and push{ kind: "streak", title: "7-day streak", days: 7 }ontoentries— but do NOT add acase "streak"todescribe. Predict what the run prints for the streak entry, and whattsc --strictwould say. Under the runner (types stripped) the streak entry falls through to thedefault, whereconst _never = entryjust aliases the entry, sodescribereturns the entry object and the loop prints{ kind: 'streak', title: '7-day streak', days: 7 }— a garbled report line, no crash. The three original checks still pass (they never touch the streak entry), so it exits 0. Buttsc --strictwould have caught it:error TS2322: Type 'StreakEntry' is not assignable to type 'never'.at the exhaustiveness line — the never check turning a forgotten case into a compile error the runner alone would miss.
Capstone milestone
Milestone — the report. The tracker renders its console report by narrowing each entry to one variant of a discriminated union and printing only that variant's own fields, with a never check so a forgotten kind fails to compile. The describe/countQuizzes pair you just built is that reporter in miniature. Confirm you can drive a report by narrowing rather than casting.
Hint: This is the reporting milestone, shared with Debugging TypeScript (exhaustiveness checks harden the reporter). Here narrowing per discriminated-union variant is the load-bearing part: in the capstone this same shape renders the tracker's console report, one variant at a time.
- Switched on the discriminant so each case reads only its own variant's fields — no casts
- Ended the switch with a never exhaustiveness check that catches a forgotten kind at compile time
- Wrote a user-defined type guard (entry is QuizEntry) and drove a typed .filter with it
- Rendered each entry from the fields the compiler proved it has, not from ones you assumed
Key Takeaways
typeofnarrows primitive types:string,number,boolean,bigint,symbol,undefined,function, andobject.- Truthiness and nullish checks (
!== null,!== undefined,if (value)) eliminatenullandundefinedfrom a type. instanceofnarrows class instances and is ideal for handling error hierarchies.- Discriminated unions use a shared literal-type field so TypeScript can narrow exhaustively in a
switch— prefer these for domain modelling. innarrows by property presence and is useful when you can't add a discriminant to existing types.- User-defined type guards (
value is T) teach TypeScript about custom narrowing logic and enable properly-typed.filter()calls. - TypeScript's control flow analysis is path-sensitive — it tracks possible types independently on each branch, so you never need to cast with
asjust to call a method.
Pro Tip: If you ever feel tempted to write
as SomeType, stop and ask whether a discriminated union or type guard would solve the problem instead. Casts silence the compiler without giving it new information — narrowing teaches the compiler, making the rest of your code safer as a result.
Next Steps
Type narrowing works hand-in-hand with classes, where instanceof checks are the most natural way to narrow. Next, you'll learn how to define classes with access modifiers, inheritance, abstract methods, and interface contracts.
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.