In the previous tutorial, we learned about enums and const assertions. Now let’s learn about type narrowing — one of the most important concepts in TypeScript.

By the end of this tutorial, you will know how to use typeof, instanceof, custom type guards, discriminated unions with switch, and exhaustive checking with never.

What is Type Narrowing?

Type narrowing means making a type more specific within a block of code. When you have a union type like string | number, TypeScript can figure out the exact type based on your checks.

function printValue(value: string | number): void {
  // Here, value is string | number

  if (typeof value === "string") {
    // Here, value is string
    console.log(value.toUpperCase());
  } else {
    // Here, value is number
    console.log(value.toFixed(2));
  }
}

TypeScript analyzes your code flow and narrows the type automatically. You do not need to cast anything.

typeof Guards

The typeof operator checks the JavaScript runtime type of a value. TypeScript understands these checks:

function process(value: string | number | boolean): string {
  if (typeof value === "string") {
    return value.toUpperCase();
  }
  if (typeof value === "number") {
    return value.toFixed(2);
  }
  // TypeScript knows value is boolean here
  return value ? "yes" : "no";
}

console.log(process("hello"));  // "HELLO"
console.log(process(3.14159));  // "3.14"
console.log(process(true));     // "yes"

typeof works for these types: "string", "number", "boolean", "bigint", "symbol", "undefined", "object", "function".

Note: typeof null returns "object" in JavaScript. This is a famous bug from 1995 that was never fixed. Be careful:

function printLength(value: string | null): void {
  if (typeof value === "object") {
    // Danger! value could be null here
    // console.log(value.length); // Error at runtime
  }
}

Truthiness Narrowing

You can narrow types by checking if a value is truthy:

function printName(name: string | null | undefined): void {
  if (name) {
    // name is string here (null and undefined are falsy)
    console.log(name.toUpperCase());
  } else {
    console.log("No name provided");
  }
}

Falsy values in JavaScript: false, 0, "", null, undefined, NaN. Everything else is truthy.

Be careful with empty strings and zero:

function processCount(count: number | null): void {
  if (count) {
    console.log(`Count: ${count}`);
  } else {
    // This runs for null AND for 0
    console.log("No count");
  }
}

processCount(0);    // "No count" — probably not what you want
processCount(null); // "No count"

Use explicit null checks instead:

function processCount(count: number | null): void {
  if (count !== null) {
    console.log(`Count: ${count}`); // Works correctly for 0
  }
}

Equality Narrowing

TypeScript narrows types when you compare values with ===, !==, ==, or !=:

function compare(a: string | number, b: string | boolean): void {
  if (a === b) {
    // a and b are both string here (the only common type)
    console.log(a.toUpperCase());
    console.log(b.toUpperCase());
  }
}

Null checks with equality:

function process(value: string | null | undefined): void {
  if (value != null) {
    // value is string here
    // != null removes both null AND undefined
    console.log(value.toUpperCase());
  }
}

Using != null (loose inequality) removes both null and undefined at once. This is one of the few cases where != (instead of !==) is recommended.

instanceof Guards

The instanceof operator checks if an object is an instance of a class:

function formatDate(value: string | Date): string {
  if (value instanceof Date) {
    return value.toISOString();
  }
  return value; // TypeScript knows it is string here
}

console.log(formatDate(new Date()));     // "2026-05-28T08:00:00.000Z"
console.log(formatDate("2026-05-28"));   // "2026-05-28"

This works with any class, including your own:

class ApiError {
  constructor(public message: string, public code: number) {}
}

class NetworkError {
  constructor(public message: string) {}
}

function handleError(error: ApiError | NetworkError): void {
  if (error instanceof ApiError) {
    console.log(`API Error ${error.code}: ${error.message}`);
  } else {
    console.log(`Network Error: ${error.message}`);
  }
}

in Operator Narrowing

The in operator checks if a property exists on an object. TypeScript uses this for narrowing:

type Fish = { swim: () => void };
type Bird = { fly: () => void };

function move(animal: Fish | Bird): void {
  if ("swim" in animal) {
    animal.swim(); // TypeScript knows animal is Fish
  } else {
    animal.fly();  // TypeScript knows animal is Bird
  }
}

This is useful when you cannot use instanceof (for example, with plain objects).

Discriminated Unions with switch

We learned about discriminated unions in Tutorial #6. Here we will add exhaustive checking to make them even safer.

type Circle = { kind: "circle"; radius: number };
type Rectangle = { kind: "rectangle"; width: number; height: number };
type Triangle = { kind: "triangle"; base: number; height: number };
type Shape = Circle | Rectangle | Triangle;

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
  }
}

Exhaustive Checking with never

What happens if you add a new shape but forget to handle it? By default, TypeScript might not warn you. You can use the never type to force exhaustive checks:

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
    default:
      // If all cases are handled, shape is never
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

Now if you add a new shape:

type Square = { kind: "square"; side: number };
type Shape = Circle | Rectangle | Triangle | Square;

TypeScript will show an error at the default case: “Type ‘Square’ is not assignable to type ’never’.” This forces you to handle the new variant.

A helper function makes this cleaner:

function assertNever(value: never): never {
  throw new Error(`Unhandled value: ${JSON.stringify(value)}`);
}

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
    default:
      return assertNever(shape);
  }
}

Custom Type Guards

Sometimes TypeScript cannot narrow a type automatically. You can write your own type guard with the is keyword:

type Fish = { name: string; swim: () => void };
type Bird = { name: string; fly: () => void };

function isFish(animal: Fish | Bird): animal is Fish {
  return "swim" in animal;
}

function move(animal: Fish | Bird): void {
  if (isFish(animal)) {
    animal.swim(); // TypeScript knows animal is Fish
  } else {
    animal.fly();  // TypeScript knows animal is Bird
  }
}

The return type animal is Fish is a type predicate. It tells TypeScript: “if this function returns true, the parameter is a Fish.”

Validating Unknown Data

Custom type guards are great for validating data from external sources:

type User = {
  name: string;
  age: number;
  email: string;
};

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "name" in value &&
    "age" in value &&
    "email" in value &&
    typeof (value as User).name === "string" &&
    typeof (value as User).age === "number" &&
    typeof (value as User).email === "string"
  );
}

// Use it to validate API data
const data: unknown = JSON.parse('{"name": "Alex", "age": 25, "email": "alex@example.com"}');

if (isUser(data)) {
  console.log(`User: ${data.name}, ${data.age}`); // Safe to access
} else {
  console.log("Invalid user data");
}

Assertion Functions

An assertion function throws an error if a condition is not met. Use the asserts keyword:

function assertString(value: unknown): asserts value is string {
  if (typeof value !== "string") {
    throw new Error(`Expected string, got ${typeof value}`);
  }
}

function processInput(input: unknown): void {
  assertString(input);
  // After assertion, TypeScript knows input is string
  console.log(input.toUpperCase());
}

The difference from type guards: assertion functions throw instead of returning a boolean. After calling an assertion function, the type is narrowed for the rest of the block.

function assertDefined<T>(value: T | null | undefined): asserts value is T {
  if (value === null || value === undefined) {
    throw new Error("Value is null or undefined");
  }
}

function getUser(): { name: string } | null {
  return { name: "Alex" };
}

const user = getUser();
assertDefined(user);
console.log(user.name); // OK — TypeScript knows user is not null

Quick Practice

Create src/narrowing.ts and try these:

// Discriminated union with exhaustive check
type Success = { type: "success"; data: string };
type Failure = { type: "failure"; error: string };
type Loading = { type: "loading" };
type State = Success | Failure | Loading;

function assertNever(value: never): never {
  throw new Error(`Unhandled: ${JSON.stringify(value)}`);
}

function renderState(state: State): string {
  switch (state.type) {
    case "success":
      return `Data: ${state.data}`;
    case "failure":
      return `Error: ${state.error}`;
    case "loading":
      return "Loading...";
    default:
      return assertNever(state);
  }
}

// Custom type guard
function isString(value: unknown): value is string {
  return typeof value === "string";
}

// Test type narrowing
const values: unknown[] = ["hello", 42, true, null, "world"];
const strings = values.filter(isString);
console.log(strings); // ["hello", "world"]

// Test discriminated unions
const states: State[] = [
  { type: "loading" },
  { type: "success", data: "User list loaded" },
  { type: "failure", error: "Network timeout" },
];

states.forEach((state) => console.log(renderState(state)));

Run it:

npx tsx src/narrowing.ts

Output:

[ 'hello', 'world' ]
Loading...
Data: User list loaded
Error: Network timeout

What is Next?

In the next tutorial, we will learn about generics — how to write functions, interfaces, and classes that work with any type while keeping full type safety.