TypeScript Type Narrowing and User-Defined Guards
A practical guide to narrowing in TypeScript — typeof, instanceof, in, discriminated unions, custom predicates with `x is T`, assertion functions, and exhaustive checks with never.
What you'll learn
- ✓How TypeScript narrows union types in branches of an `if`
- ✓The built-in narrowing operators — typeof, instanceof, in, equality
- ✓How discriminated unions make complex narrowing trivial
- ✓How to write user-defined type guards with `x is T`
- ✓How assertion functions (`asserts x is T`) work and when to use them
- ✓How to enforce exhaustive checks with the `never` type
Prerequisites
- •You understand union types — see Interfaces and Type Aliases
Once you start using union types seriously — string | number, User | null, a tagged union of shapes — the compiler stops you from doing operations that are only valid on some of the variants. Narrowing is the mechanism that gets the variant you want back. This post covers every form of narrowing TypeScript supports and shows you how to extend the system with your own type guards.
What narrowing actually means
Consider a union:
function format(input: string | number): string {
return input.toFixed(2); // Error: Property 'toFixed' does not exist on type 'string'.
}
toFixed only exists on number. The compiler refuses because input might be a string. You need to prove to the compiler that, at this exact line, input is a number. That proof is narrowing:
function format(input: string | number): string {
if (typeof input === "number") {
return input.toFixed(2); // here, input is number
}
return input.toUpperCase(); // here, input is string
}
The typeof check filters the union. Inside the if, input has type number; in the else branch, it has type string. The compiler does this analysis flow-sensitively — what you know about a value depends on which code path you are on.
Narrowing with typeof
typeof works for JavaScript’s primitive types: "string", "number", "boolean", "bigint", "symbol", "undefined", "object", "function".
function describe(value: string | number | boolean): string {
if (typeof value === "string") return `string of length ${value.length}`;
if (typeof value === "number") return `number worth ${value.toFixed(0)}`;
return value ? "true" : "false"; // value is boolean here
}
Note the trap: typeof null === "object". If your union includes null, typeof x === "object" will not exclude it. Use an explicit === null check instead.
Narrowing with instanceof
instanceof narrows class instances:
class HttpError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
function explain(err: Error): string {
if (err instanceof HttpError) {
return `HTTP ${err.status}: ${err.message}`; // err is HttpError
}
return err.message;
}
This works for any constructor function. It does not work for plain object types ({ name: string }) — there is no runtime class to check against.
Narrowing with in
The in operator checks for a property’s presence and narrows accordingly:
interface Cat { meow(): void; }
interface Dog { bark(): void; }
function speak(animal: Cat | Dog): void {
if ("meow" in animal) {
animal.meow(); // animal is Cat
} else {
animal.bark(); // animal is Dog
}
}
This is handy when the variants have no shared discriminator and you cannot add one. Prefer a discriminated union when you control the shapes — it is clearer and less fragile.
Equality narrowing
Comparing a variable to a literal narrows it:
function step(direction: "left" | "right" | "up" | "down"): void {
if (direction === "left" || direction === "right") {
// direction is "left" | "right"
} else {
// direction is "up" | "down"
}
}
The same works against null and undefined, which is the common way to filter optional values:
function shout(s: string | null): string {
if (s === null) return "";
return s.toUpperCase(); // s is string
}
Or the more compact pattern:
function shout2(s: string | null): string {
return s?.toUpperCase() ?? "";
}
Truthiness narrowing
A plain if (value) removes the falsy variants from the type — null, undefined, 0, "", false, NaN.
function greet(name: string | undefined): string {
if (name) {
return `Hello, ${name}`; // name is string
}
return "Hello, stranger";
}
Be careful with numbers and strings: if (count) is false for both undefined and 0. If 0 is a legal value, prefer if (count !== undefined).
Discriminated unions
This is the cleanest pattern in real codebases. Each variant of the union carries a literal tag field, and switching on the tag narrows the rest of the shape.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "rect"; width: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "square": return shape.side ** 2;
case "rect": return shape.width * shape.height;
}
}
The compiler reads the kind literal and exposes only the matching shape’s fields in that branch. If you forget a case, you can ask the compiler to tell you — that is the next section.
Try it yourself. Add a "triangle" variant with base: number and height: number to Shape. Do not update area. Read the error message at the function’s return type. Then add the case, and see the error disappear.
Exhaustiveness with never
The never type holds no values. If a branch is supposed to be unreachable, you can assign the remaining variable to never. If a real value could reach it, the compiler will complain — which is exactly the bug you want to catch.
function area(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "square": return shape.side ** 2;
case "rect": return shape.width * shape.height;
default: {
const _exhaustive: never = shape; // Error if Shape grows
return _exhaustive;
}
}
}
When you later add "triangle" to Shape and forget to update area, the assignment fails to type-check because shape is now a non-empty type at that point. This is one of TypeScript’s best refactoring safety nets.
User-defined type guards: x is T
The built-in narrowing operators only get you so far. When you need a custom rule, you write a function whose return type is a type predicate:
interface User {
id: number;
email: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
typeof (value as User).id === "number" &&
"email" in value &&
typeof (value as User).email === "string"
);
}
function send(value: unknown): void {
if (isUser(value)) {
console.log(value.email); // value is User here
}
}
The return type value is User tells the compiler: “if this function returned true, then the argument is a User.” Past that if, the type is narrowed.
The body of the guard is your responsibility — TypeScript trusts it. A buggy guard introduces an unsound narrowing, so write the runtime checks carefully and prefer libraries like Zod or Valibot for non-trivial shapes.
Negative predicates
A guard can be written to narrow on the false branch instead — useful for filtering:
function isDefined<T>(value: T | undefined): value is T {
return value !== undefined;
}
const raw: (string | undefined)[] = ["a", undefined, "b"];
const clean: string[] = raw.filter(isDefined); // typed correctly
Without the predicate, filter would return (string | undefined)[]. With it, the compiler propagates the narrowing through the array.
Assertion functions: asserts x is T
An assertion function does not return a boolean — it throws if the assertion fails, and the compiler treats every subsequent line as if the assertion held.
function assertIsUser(value: unknown): asserts value is User {
if (!isUser(value)) {
throw new TypeError("Not a User");
}
}
function send(value: unknown): void {
assertIsUser(value);
console.log(value.email); // value is User from here on
}
There is no if. The call statement itself shifts the type for the rest of the scope. This is how libraries like Node’s assert and Vitest’s assert interact with the type system.
Two rules to remember:
- The return type must be
void(orasserts ...). - The function must actually throw on failure. Returning normally on failure is unsound and the compiler cannot catch that mistake.
A second form asserts a condition rather than a type:
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function tail<T>(arr: T[]): T {
assert(arr.length > 0, "empty");
return arr[arr.length - 1]; // no `T | undefined` here
}
After assert, the compiler treats condition as truthy.
Narrowing and reassignment
Narrowing is lost when you reassign or call into other code that might mutate the value:
function example(input: string | number): void {
if (typeof input === "number") {
input.toFixed(2); // ok
input = "now a string";
input.toFixed(2); // Error: input is string now
}
}
Inside a closure, the compiler is conservative — it cannot know whether an outer variable was reassigned between the check and the use:
function example(maybeName: string | null): () => string {
if (maybeName === null) return () => "";
return () => maybeName.toUpperCase(); // Error: maybeName: string | null
}
Pin the narrowed value to a const:
function example(maybeName: string | null): () => string {
if (maybeName === null) return () => "";
const name = maybeName;
return () => name.toUpperCase(); // ok
}
Putting it together
A small worked example combining several techniques.
type Event =
| { type: "click"; x: number; y: number }
| { type: "keypress"; key: string }
| { type: "scroll"; deltaY: number };
function handle(event: Event): string {
switch (event.type) {
case "click": return `clicked at ${event.x}, ${event.y}`;
case "keypress": return `pressed ${event.key}`;
case "scroll": return `scrolled ${event.deltaY}`;
default: {
const _exhaustive: never = event;
return _exhaustive;
}
}
}
function isEvent(value: unknown): value is Event {
return (
typeof value === "object" &&
value !== null &&
"type" in value &&
typeof (value as Event).type === "string"
);
}
function dispatch(raw: unknown): string | null {
if (!isEvent(raw)) return null;
return handle(raw);
}
A discriminated union for the data, a switch with exhaustive never for safety, and a custom guard at the system boundary where unknown data comes in. This shape — guarded boundaries, narrowed core — is how production TypeScript stays honest.
Try it yourself. Add a "hover" variant to Event with { targetId: string }. Watch the error in handle point at the exhaustive never. Add the case, then write a isClick(event: Event): event is Extract<Event, { type: "click" }> predicate and call it from a test.
Recap
You now know:
- Narrowing filters a union based on a runtime check, flow-sensitively per branch.
typeof,instanceof,in, equality, and truthiness are all narrowing operators built into the language.- Discriminated unions with a literal tag field plus a
switchare the cleanest pattern for complex variants. - A
defaultbranch that assigns toneverenforces exhaustiveness when the union grows. - A function with return type
x is Tis a user-defined guard — the compiler trusts the body. - A function with return type
asserts x is Tis an assertion function — it shifts the type for the rest of the scope. - Narrowing can be lost across reassignment and closures; pin values to
const.
Next steps
The next post in the series covers how TypeScript code is actually organised across files — ESM imports and exports, type-only imports, barrel files, and the moduleResolution setting that decides what import "./foo" means.
→ Next: TypeScript Modules, Imports, and Exports
Related: Interfaces and Type Aliases, Generics Basics.
Questions or feedback? Email codeloomdevv@gmail.com.