Type Narrowing in TypeScript: Turning a Broad Type Into a Specific One

Learn how TypeScript narrows a broad type, like a union, down to a specific type inside a block so you can use it safely. Covers typeof, instanceof, truthiness, equality, in, discriminated unions, and type guards.

Published September 20, 202610 min readBy ACY Partner Indonesia
Type Narrowing eyebrow with a typeof check code chip on a dark ACY-branded cover
300 × 250Ad Space AvailablePlace your ad here

A union type in TypeScript is a way of saying “this value could be one of several things.” A variable might be a string or a number. A function might return a User or null. That flexibility is useful, but it comes with a catch: when a value could be several types at once, TypeScript won’t let you treat it as any single one of them. You can’t call .toUpperCase() on something that might be a number, and TypeScript is right to stop you.

So how do you actually use a union? The answer is type narrowing. Narrowing is the process of going from a broad type (“string or number”) to a specific one (“definitely a string, right here”) by writing a check that TypeScript understands. Once you prove which case you’re in, TypeScript opens up the full set of properties and methods for that type. This article walks through every common way to narrow, one step at a time, with examples you can read top to bottom.

What problem narrowing solves

Let’s start with a function that takes a value that could be a string or a number, and we want to format it.

function format(value: string | number) {
  // value.toUpperCase() // Error: Property 'toUpperCase' does not exist on type 'string | number'.
  return value;
}

TypeScript blocks value.toUpperCase() because toUpperCase exists on string but not on number. As far as the type system knows, value could still be either. The union is the broad type. Our job is to narrow it so that inside one branch of code, TypeScript is convinced the value is a string and only a string.

Here’s the same function, fixed:

function format(value: string | number) {
  if (typeof value === "string") {
    return value.toUpperCase(); // OK: value is string here
  }
  return value.toFixed(2); // OK: value is number here
}

Notice that we never told TypeScript “trust me, it’s a string.” We wrote a real runtime check, and TypeScript followed our logic. That is the heart of narrowing: you write ordinary JavaScript checks, and the type system reads along with you.

Narrowing is about flow, not casting

Narrowing changes what TypeScript believes a value is within a specific block of code, based on a check that also runs at runtime. It is not the same as a type assertion (value as string), which silences the compiler without any real check. Prefer narrowing whenever you can.

Narrowing with typeof

The typeof operator is JavaScript’s built-in way to ask “what primitive kind of value is this?” TypeScript recognizes typeof checks and uses them to narrow. The strings typeof can return that matter most are "string", "number", "boolean", "object", "function", "undefined", "bigint", and "symbol".

function describe(value: string | number | boolean) {
  if (typeof value === "string") {
    return `text of length ${value.length}`;
  }
  if (typeof value === "number") {
    return `number rounded to ${Math.round(value)}`;
  }
  return `boolean that is ${value ? "true" : "false"}`;
}

Inside the first if, value is a string. Inside the second, it’s a number. After both checks fail, only boolean remains, so TypeScript narrows it to boolean automatically in the final line. That last point is worth remembering: narrowing also works by elimination.

typeof null is 'object'

A famous JavaScript quirk: typeof null returns "object", not "null". So typeof value === "object" does not guarantee you have a real object, it might be null. To narrow out null, use a different check, which we cover below.

Narrowing with truthiness

Often the union you’re dealing with is something like string | null or User | undefined. You don’t need typeof for that, a plain truthiness check is enough. In JavaScript, values like null, undefined, 0, "", NaN, and false are “falsy”; everything else is “truthy.” TypeScript uses a truthiness check to remove the falsy possibilities from the type.

function greet(name: string | null) {
  if (name) {
    // name is string here, null has been ruled out
    return `Hello, ${name.toUpperCase()}!`;
  }
  return "Hello, guest!";
}

This is the most common narrowing you’ll write in real code, because functions return null or undefined all the time. Be a little careful, though: a truthiness check on a string also removes the empty string "", and on a number it removes 0. If 0 or "" are valid values you care about, check explicitly with name !== null instead.

Narrowing with equality

When you compare two values with ===, !==, ==, or !=, TypeScript can narrow both sides. If two values are strictly equal, they must share a type, so the type system intersects what it knows about each.

function compare(a: string | number, b: string | boolean) {
  if (a === b) {
    // The only type both can share is string,
    // so here a is string and b is string.
    return a.toUpperCase() === b.toUpperCase();
  }
  return false;
}

Equality narrowing also shines when comparing against a literal. Checking value === "loading" narrows value to that exact string. And comparing against null or undefined is a clean way to remove them:

function trim(value: string | undefined) {
  if (value !== undefined) {
    return value.trim(); // value is string
  }
  return "";
}

Narrowing with the in operator

The in operator checks whether an object has a property with a given name. When your union is made of object types with different shapes, in is a tidy way to tell them apart.

type Admin = { role: "admin"; permissions: string[] };
type Guest = { role: "guest"; sessionId: string };

function summarize(account: Admin | Guest) {
  if ("permissions" in account) {
    // account is Admin, because only Admin has 'permissions'
    return `Admin with ${account.permissions.length} permissions`;
  }
  // account is Guest here
  return `Guest session ${account.sessionId}`;
}

This works because the two shapes have different keys. The moment TypeScript confirms the permissions key exists, it knows you must be holding an Admin.

Narrowing with instanceof

For values created from a class, instanceof asks “was this object built from this constructor?” TypeScript narrows accordingly. This is handy with built-in classes like Date and Error, as well as your own classes.

function formatDate(input: Date | string) {
  if (input instanceof Date) {
    return input.toISOString(); // input is Date
  }
  return input; // input is string
}

A common real-world use is error handling. In a catch block, the caught value has type unknown, and instanceof Error is the safe way to confirm you actually have an Error before reading .message.

try {
  doSomethingRisky();
} catch (err) {
  if (err instanceof Error) {
    console.error(err.message);
  } else {
    console.error("Unknown failure", err);
  }
}

Discriminated unions: the cleanest pattern

When you control the shape of your types, there’s a pattern that makes narrowing almost effortless: the discriminated union. The idea is to give every member of the union a shared property, usually called something like kind, type, or status, that holds a unique literal value. That shared property is the “discriminant,” and checking it narrows the whole object in one move.

type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number };
type Rectangle = { kind: "rectangle"; width: number; height: number };

type Shape = Circle | Square | Rectangle;

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2; // shape is Circle
    case "square":
      return shape.side ** 2; // shape is Square
    case "rectangle":
      return shape.width * shape.height; // shape is Rectangle
  }
}

Here’s the flow in plain terms:

shape: Circle | Square | Rectangle

        ▼  switch (shape.kind)
   ┌────┴─────┬──────────┐
"circle"   "square"  "rectangle"
   │           │           │
 radius      side     width,height

Because each kind value is unique, TypeScript can match a single property and unlock exactly the right shape inside each case. Discriminated unions scale beautifully: API responses ({ status: "success" } | { status: "error" }), Redux-style actions, and state machines all use this pattern.

Let TypeScript catch missing cases

Add a default branch with const _exhaustive: never = shape;. If you later add a new shape to the union but forget to handle it, TypeScript will report an error there, because the leftover type is no longer assignable to never. This turns a forgotten case into a compile-time error instead of a silent bug.

Type guards: narrowing you write yourself

Sometimes the check you need is too specific for typeof or in. You can teach TypeScript a custom check by writing a function whose return type is a type predicate, written as value is SomeType. When that function returns true, TypeScript narrows the argument to SomeType.

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

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

function welcome(value: unknown) {
  if (isUser(value)) {
    // value is User here, thanks to the predicate
    return `Welcome, ${value.name}`;
  }
  return "Unknown visitor";
}

The body of isUser is ordinary runtime code. The special part is the return type value is User. That promise tells the compiler: “if I return true, treat the argument as a User from here on.” Custom type guards are how you validate data coming from outside your program, like a JSON response from an API, and convert unknown into something typed and safe.

A type predicate is a promise you must keep

TypeScript trusts your value is User predicate without verifying it. If your function returns true for something that is not actually a User, the type system will be wrong and you’ll get runtime errors. Make the body genuinely check every property the type claims to have.

Putting the methods side by side

Check Best for Example
typeof Primitives (string, number, boolean…) typeof x === "string"
Truthiness Removing null / undefined / falsy if (value)
Equality Literals, ruling out specific values x === "ready"
in Objects with different keys "id" in obj
instanceof Class instances, Error, Date e instanceof Error
Discriminated union Object unions you designed switch (s.kind)
Type guard Custom or external-data checks isUser(value)

Recap

Narrowing is the bridge between flexible union types and safe, specific code. A union tells TypeScript “this could be several things,” and TypeScript refuses to assume which until you prove it. Every technique here is a way of giving that proof:

  • typeof narrows primitives, and narrowing-by-elimination handles the leftover case.
  • Truthiness is the quick way to drop null and undefined, just watch out for 0 and "".
  • Equality narrows both sides of a comparison and is great with literals.
  • in separates object shapes by their keys, while instanceof separates class instances.
  • Discriminated unions are the cleanest pattern when you design the types: one shared literal property unlocks the rest.
  • Type guards with a value is Type predicate let you write checks TypeScript can’t infer on its own, which is essential for validating outside data.

Once narrowing clicks, unions stop feeling fragile and start feeling like exactly the right tool: you describe every possibility up front, then handle each one with confidence. The compiler walks through your logic with you and keeps you honest in every branch.

Tags:typescripttype-narrowingunion-typestype-guardsfrontend
728 × 90Ad Space AvailablePlace your ad here

Related Articles

See All Articles

You Might Also Like

Frontend best practices overview cover
Frontend / Fundamentals

Frontend Best Practices: An Overview

A friendly map of what good frontend really means — semantic HTML, clean CSS, unobtrusive JavaScript, responsive layouts, performance, and progressive enhancement, with pointers to go deeper.

Sep 20, 20268 min read
A Frontend Developer Roadmap cover with the path HTML to CSS to JavaScript
Frontend / Fundamentals

A Frontend Developer Roadmap: What to Learn, and in What Order

A calm, step-by-step learning path for beginners: HTML, CSS, JavaScript, developer tools, responsive and accessible design, a framework, TypeScript, and deployment, with the reasoning behind each step.

Sep 20, 202611 min read
Title card reading Styling and Interactivity over a dark blue ACY Partner background
Frontend / Fundamentals

How Styling and Interactivity Work

A beginner-friendly look at how CSS turns plain HTML into a designed layout, and how JavaScript adds behavior — the structure, style, behavior mental model for building web pages.

Sep 20, 20269 min read
Three front-end layers combining on one web page
Frontend / Fundamentals

How HTML, CSS, and JavaScript Work Together

A hands-on walkthrough for beginners: build one small button, give it structure with HTML, looks with CSS, and behavior with JavaScript, and watch the three layers cooperate on a real page.

Sep 20, 20268 min read
The Frontend Developer Toolkit cover with editor, browser, and CLI motifs
Frontend / Fundamentals

The Frontend Developer Toolkit

A friendly tour of the everyday tools a frontend developer relies on — code editor, browser and DevTools, terminal, version control, package managers, and a dev server — and what each one is actually for.

Sep 20, 202610 min read
Cover illustration for What Frontend Developers Do
Frontend / Fundamentals

What Frontend Developers Do: A Beginner's Guide to the Role

A clear, beginner-friendly look at what frontend developers actually do every day: turning designs into working interfaces, building reusable UI, and caring about responsiveness, accessibility, and speed.

Sep 20, 202610 min read