Interview Q&A / TypeScript

IntermediateIn-depth3 min readUpdated 2 Oct 2026

What is type narrowing in TypeScript, and how do type guards work?

SHORT ANSWER

Narrowing is how TypeScript refines a broad type, like string | number, to a more specific one inside a block after a check. Type guards are those checks, such as typeof, instanceof, the in operator, equality checks on a shared field, and custom functions that return a type predicate like value is User.

Explain it simply

At a railway station, the announcement says "a train is arriving on platform 3". You do not know yet if it is a local or an express. Once you read the board and see "Rajdhani", you know it is an express and you behave accordingly.

TypeScript does the same with your code. A variable might be string | number. After you write if (typeof value === "string"), TypeScript knows that inside that block it is a string, so it lets you call toUpperCase(). That refining is narrowing, and the check that causes it is a type guard.

Example

ts
// 1. typeof: for primitives
function format(value: string | number): string {
  if (typeof value === "string") {
    return value.toUpperCase(); // value: string
  }
  return value.toFixed(2);      // value: number
}

// 2. instanceof: for classes
function describe(input: Date | Error): string {
  if (input instanceof Date) {
    return input.toISOString(); // input: Date
  }
  return input.message;         // input: Error
}

// 3. in: checks if a property exists
type Upi = { upiId: string };
type Card = { cardNumber: string; expiry: string };

function payLabel(method: Upi | Card): string {
  if ("upiId" in method) {
    return `UPI ${method.upiId}`;
  }
  return `Card ending ${method.cardNumber.slice(-4)}`;
}

// 4. Discriminated union: a shared literal field
type ApiState =
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; message: string };

function render(state: ApiState): string {
  switch (state.status) {
    case "loading":
      return "Loading...";
    case "success":
      return state.data.join(", "); // state has data here
    case "error":
      return state.message;         // state has message here
  }
}

// 5. Custom type guard with a type predicate
interface User {
  id: number;
  name: string;
}

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

const parsed: unknown = JSON.parse('{"id":1,"name":"Ravi"}');
if (isUser(parsed)) {
  console.log(parsed.name); // parsed: User
}

How it works

TypeScript performs control flow analysis. It follows if, else, switch, return and throw and tracks what each variable could be at each line. When a branch rules out some types, those types are removed for the rest of that branch.

Other checks also narrow:

  • Truthiness: if (name) removes null and undefined (and also empty string, so be careful).
  • Equality: if (x === null) or x !== undefined.
  • Array.isArray(x) narrows to an array.
  • Assertion functions: function assertIsString(x: unknown): asserts x is string narrows everything after the call, by throwing if the check fails.

A type predicate value is User is a promise you make to the compiler. TypeScript trusts it completely and does not verify your logic. Since TypeScript 5.5, simple arrow functions like x => x !== undefined can have their predicate inferred, which makes array.filter(x => x !== undefined) return a narrowed array.

Real-world use

  • Handling API responses with a status or type field (discriminated unions), and Redux-style actions.
  • Validating unknown data from JSON.parse, localStorage or fetch.
  • Handling errors: catch (e) gives unknown in strict mode, so check e instanceof Error before reading e.message.

Common mistakes

  • Forgetting that typeof null === "object". Always pair typeof x === "object" with x !== null.
  • Writing a custom guard whose logic does not match its predicate. The compiler will believe it, and the bug reaches runtime.
  • Using truthiness to rule out null on numbers or strings, which also discards 0 and "".
  • Using as to force a type instead of narrowing. A cast skips the check; a guard performs it.
  • Expecting instanceof to work with interfaces or type aliases. They do not exist at runtime; use in or a discriminant field.

What to say in the interview

"Narrowing is TypeScript's control flow analysis refining a union to a more specific type after a check. The built-in type guards are typeof for primitives, instanceof for classes, the in operator for property checks, equality and truthiness checks, and Array.isArray. For my own types I prefer discriminated unions with a literal field like status, and switch on it. For untrusted data I write a custom type guard that returns a predicate like value is User, keeping in mind that the compiler trusts the predicate, so the runtime check must be correct."

Follow-up questions

  • What is a discriminated union?
  • What is the difference between a type predicate and an assertion function?
  • Why doesn't instanceof work with interfaces?
  • How do you make a switch on a union exhaustive?

Go deeper

Read these once the basics above feel easy.

Narrowing is a compile-time trade-off, not a guarantee. TypeScript does not forget a narrowed property like obj.value after you call another function, even though that function could have changed it. This keeps code readable but can be unsound. In the other direction, narrowing of a let variable is lost inside a callback if the variable is reassigned later, because the callback might run after the reassignment (TypeScript 5.4 improved this for closures created after the last assignment). Copying the value into a const before the check avoids both problems. Also, in narrowing keeps union members that declare the property as optional in both branches, since the property might or might not be present.

#narrowing#type-guards#unions#control-flow

Related questions

Practise more TypeScript questions

Test your recall with short answers, then return to the detailed explanations when you need context.

Open TypeScript quick answers →

Get new questions like this by email

One short email when something new and useful lands. No spam, unsubscribe in one tap.

By subscribing you agree to the Privacy Policy.