TypeScript NotesRohit’s interview study guide
Chapter 04

Unions, Narrowing & Type Guards

Combining types with unions and intersections, then narrowing them safely with typeof, instanceof, in, custom guards and discriminated unions.

12 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Union types

A union type A | B says "this value is one of these, and right now I don't know which". It's easiest to think of types as sets of values: string is the set of every string, number the set of every number, and string | number is the bigger set that holds both. Unions exist because real JavaScript values are often one of several things: an ID that might be a string or a number, a parameter that accepts a selector or an element, an API result that is either data or an error, or simply User | null for "might be missing".

The price of that flexibility: while you hold a union, TypeScript only lets you do what is safe for every member. If id might be a number, id.toUpperCase() could crash, so it's refused. To use something member-specific you narrow: you write an ordinary runtime check (typeof, ===, instanceof, in …), and the compiler follows your ifs, returns and switches and works out a more precise type for the variable in each branch. This is called control flow analysis. A variable has one declared type, but at every point in the code it also has a narrowed type: whatever is left after the checks that must have passed to get there.

A mental model: a union is a sealed envelope that holds one of several things. Outside the if, you can only do what works for anything that might be inside. The check is you opening the envelope; inside each branch you know exactly what you're holding.

The example from my notes — I added toUpperCase() and toFixed() so each branch uses something only its member has:

TypeScript
function printId(id: string | number): void {
  if (typeof id === "string") {
    console.log(`ID is a string: ${id.toUpperCase()}`);
  } else {
    console.log(`ID is a number: ${id.toFixed(2)}`);
  }
}

printId("abc-123"); // ID is a string: ABC-123
printId(42); // ID is a number: 42.00
What’s happening
  1. At the top of the function, id has its declared type string | number. The only members you could use here are the ones both types share, like toString() and valueOf().
  2. typeof id === "string" is a plain JavaScript check — it survives compilation unchanged and runs at runtime. TypeScript recognises the pattern as a type guard, so inside the if block it treats id as string, and id.toUpperCase() compiles.
  3. In the else branch the checker subtracts what was ruled out: string | number minus string leaves number. That's why id.toFixed(2) compiles there without any extra check.
  4. At runtime, printId("abc-123") takes the first branch and logs ID is a string: ABC-123; printId(42) takes the else and logs ID is a number: 42.00.
  5. Once the if/else is over, the two branches join again and id is back to string | number. Narrowing doesn't change the variable's type permanently — it's a fact about one place in the code.
AdvancedErrors without a narrowing check

Here is what the compiler stops you doing without a check:

TypeScript
function shout(id: string | number) {
  console.log(id.toString()); // fine: both string and number have toString()
  return id.toUpperCase(); // ❌ Property 'toUpperCase' does not exist on type 'string | number'.
}
What’s happening
  1. id.toString() compiles because toString exists on string and on number. Whatever id turns out to be at runtime, the call works.
  2. id.toUpperCase() fails with TS2339: Property 'toUpperCase' does not exist on type 'string | number'. The second line of the error, Property 'toUpperCase' does not exist on type 'number'., names the member that's the problem.
  3. The rule: on a union you can only use the members that every member of the union has. The compiler isn't being fussy — if someone called shout(42), (42).toUpperCase() really would throw a TypeError.
  4. With objects this rule is the source of the classic "unions have fewer properties" surprise — see Unions vs intersections.
AdvancedNarrowing with guard clauses

Narrowing doesn't need an if/else pair. The most common shape in real code is a guard clause that returns early, after which only the remaining types are left:

TypeScript
function initials(name: string | null | undefined): string {
  if (name == null) return "?";
  // from here on, name is string
  return name
    .split(" ")
    .map((part) => part[0])
    .join("");
}

console.log(initials("Ada Lovelace")); // AL
console.log(initials(undefined)); // ?
What’s happening
  1. name starts as string | null | undefined. Calling name.split here would fail with "'name' is possibly 'null'" (see strict and strictNullChecks).
  2. name == null uses loose equality on purpose: == null is true for both null and undefined, and TypeScript knows that, so one check removes both.
  3. If the check is true the function returns. The checker reasons: "the only way to reach the next line is that name == null was false", so after the return, name is narrowed to string.
  4. initials("Ada Lovelace") splits into ["Ada", "Lovelace"], maps to ["A", "L"] and joins to "AL". initials(undefined) hits the guard and returns "?".
AdvancedChecks the compiler treats as narrowing

The checks TypeScript understands as narrowing: typeof, instanceof, in, equality (===, !==, == null), truthiness (if (x)), Array.isArray, assignments, and your own custom type guards and assertion functions. The rest of this chapter takes them one at a time.

AdvancedPitfall: falsy values in truthiness checks
AdvancedWhere unions appear in real code

Where unions show up in real code:

  • Optional values — User | null, string | undefined. With strictNullChecks on, this is how "might be missing" is written.
  • A fixed set of options — "asc" | "desc", "GET" | "POST". Literal unions are often used instead of enums (see Enums vs literal unions).
  • Flexible inputs — string | string[], Date | number. Normalise them at the top of the function and work with one type after that.
  • States and results — loading/success/error. These work best as discriminated unions.

When not to use one: if a function's output type depends on its input type, returning a union forces every caller to narrow a result you already knew. parse(x: string | number): string | number loses the link between input and output; function overloads or a generic keep it.

#Intersection types

An intersection type A & B describes a value that is an A and a B at the same time — it has to satisfy both. For object types that means it has every property of A plus every property of B. In set terms it's the overlap: the values that belong to both sets, and an object with all four properties is in the "has street and city" set and the "has name and phone" set.

Intersections are how you compose types from smaller pieces with type aliases — the type equivalent of interface … extends (see Extending interfaces and intersecting types). They're also how you add properties to a type you don't own, like Props & { onClose(): void }, or describe what a mixin returns.

From my notes:

TypeScript
type Address = { street: string; city: string };
type Contact = { name: string; phone: string };
type Person = Address & Contact;

const ana: Person = {
  street: "1 Main St",
  city: "Toronto",
  name: "Ana",
  phone: "555-0100",
};

const noPhone: Person = { street: "1 Main St", city: "Toronto", name: "Ana" }; // ❌ Property 'phone' is missing
What’s happening
  1. Person is Address & Contact. The checker treats it as one object type with four required properties: street, city, name and phone.
  2. ana has all four, so it's assignable to both Address and Contact, which is exactly what & requires.
  3. noPhone fails with TS2322: "Type '{ street: string; city: string; name: string; }' is not assignable to type 'Person'. Property 'phone' is missing in type '…' but required in type 'Contact'." Notice it names Contact — the intersection remembers which piece each requirement came from.
  4. Nothing exists at runtime: Person, Address and Contact are erased, and ana is just a plain object.
AdvancedAmphibious vehicle: mixing interfaces and aliases

Intersections mix interfaces and type aliases freely. The "amphibious vehicle" example from my notes:

TypeScript
interface Car {
  drive(): void;
}
interface Boat {
  sail(): void;
}
type AmphibiousVehicle = Car & Boat;

const vehicle: AmphibiousVehicle = {
  drive: () => console.log("Driving on land!"),
  sail: () => console.log("Sailing on water!"),
};

vehicle.drive(); // Driving on land!
vehicle.sail(); // Sailing on water!

function park(car: Car) {
  car.drive();
}
park(vehicle); // Driving on land! — accepted because every AmphibiousVehicle is also a Car
What’s happening
  1. Car and Boat are interfaces; AmphibiousVehicle is a type alias that intersects them. The result requires both drive() and sail().
  2. The object literal provides both methods as arrow functions, so the assignment compiles. Leave either one out and you get a "missing property" error like the one above.
  3. vehicle.drive() and vehicle.sail() are both allowed, because an intersection has all the members of its parts.
  4. park(vehicle) compiles because Car & Boat is assignable to Car — an intersection is always assignable to each of its parts. It's the most specific type in the family.
AdvancedConflicting property types in intersections

Intersections don't complain when the parts disagree. They intersect the property types too — and if no value can satisfy both, you get never:

TypeScript
type WithStringId = { id: string };
type WithNumberId = { id: number };
type Both = WithStringId & WithNumberId;
// Both["id"] is string & number, which is never

const record: Both = { id: "abc" }; // ❌ Type 'string' is not assignable to type 'never'.
What’s happening
  1. Intersecting two object types intersects each shared property: id becomes string & number.
  2. No value is a string and a number at once, so string & number reduces to never — the empty set.
  3. Declaring Both produces no error. You only find out when you try to build a value and TS2322 says "Type 'string' is not assignable to type 'never'" on the id property. Compare interface B extends A { id: number }, which errors straight away at the declaration — see extends vs intersection.
  4. Special case: if the clashing property is a literal-typed discriminant, like { kind: "a" } & { kind: "b" }, TypeScript (3.9+) reduces the whole intersection to never, not just the property.
AdvancedPractical uses of intersections

Where intersections are useful:

  • Adding to an existing type — type WithId<T> = T & { id: string }, or React props: ButtonProps & { variant: "primary" | "ghost" }.
  • Mixins and helpers that merge objects — Object.assign(a, b) is typed as returning A & B.
  • Branded types — type UserId = string & { readonly __brand: "UserId" } makes a string that plain strings aren't assignable to.

For named object hierarchies, prefer interface … extends: conflicts are reported at the declaration, and the checker caches interface relationships, so it's also faster on big codebases.

#Unions vs intersections

This is the counterintuitive part. Read | as "or" and & as "and", and you'd expect a union of two object types to have the properties of either, and an intersection to have only the common ones. It's the other way round.

The names describe sets of values, not sets of properties. A union is a bigger set of values: any value of A | B could be an A or a B, so you know less about it and can only touch the properties they all share. An intersection is a smaller set of values: every value of A & B satisfies both, so you know more about it and can use every property. More possible values means fewer guaranteed properties.

TypeScript
type HasName = { name: string };
type HasAge = { age: number };

const a: HasName | HasAge = { name: "Ana" }; // OK: it's a HasName
const b: HasName | HasAge = { name: "Ana", age: 30 }; // OK: extra members are allowed
const c: HasName & HasAge = { name: "Ana", age: 30 }; // must have both

function greet(p: HasName | HasAge) {
  return p.name; // ❌ Property 'name' does not exist on type 'HasAge | HasName'.
}

type UnionKeys = keyof (HasName | HasAge); // never — no key is common to both
type IntersectionKeys = keyof (HasName & HasAge); // "name" | "age"
What’s happening
  1. a only needs to match one member of the union. { name: "Ana" } is a valid HasName, so it's accepted.
  2. b has both properties. That's still fine for the union — it matches HasName (and HasAge) — and excess-property checking accepts a property as long as some member of the union declares it.
  3. c is an intersection, so it must match every member: drop age and it fails.
  4. Inside greet, p could be a plain HasAge like { age: 30 }, which has no name. So p.name fails with TS2339, and the follow-up line "Property 'name' does not exist on type 'HasAge'" says which member is missing it. (The compiler prints the union as HasAge | HasName — the order in messages isn't the order you wrote, and it doesn't matter.)
  5. keyof makes the rule visible. The keys you can safely use on a union are the keys common to all members — here none, so never. On an intersection you get all the keys: "name" | "age".
AdvancedUnions and intersections of primitives

With primitives and literal types, the set picture is even clearer:

TypeScript
type StrOrNum = string | number; // any string or any number
type StrAndNum = string & number; // never — no value is both

type Common = ("a" | "b" | "c") & ("b" | "c" | "d"); // "b" | "c"
type OnlyNumber = (string | number) & (number | boolean); // number
What’s happening
  1. string | number is every string plus every number.
  2. string & number asks for values that are both — there are none, so it's never. This is the "never from impossible intersections" in my notes.
  3. Intersecting two unions keeps the overlap. The checker distributes: it intersects every pair ("a" & "b" is never, "b" & "b" is "b", …), drops the nevers, and is left with "b" | "c".
  4. Same idea for (string | number) & (number | boolean): the only type in both is number.
AdvancedUnion vs intersection reference table
Union A | BIntersection A & B
A value must bean A or a Ban A and a B
Set of valuesbigger (both sets together)smaller (the overlap)
Properties you can useonly the common onesall of them
keyofcommon keysall keys
string with numberstring | numbernever
To use member-specific partsnarrow firstnothing needed
Does v.age compile?
TypeScript
type HasName = { name: string };
type HasAge = { age: number };

const u: HasName | HasAge = { name: "Ana" };
u.name; // ?

const v: HasName | HasAge = { name: "Ana", age: 30 };
v.age; // ?
Show answer

u.name compiles, and v.age doesn't: "Property 'age' does not exist on type 'HasAge | HasName'. Property 'age' does not exist on type 'HasName'."

What’s happening
  1. When you assign to a variable whose declared type is a union, TypeScript narrows it to the union members the assigned value fits (assignment narrowing).
  2. { name: "Ana" } fits HasName but not HasAge, so right after the declaration u is narrowed to HasName, and u.name is allowed.
  3. { name: "Ana", age: 30 } fits both HasName and HasAge. Neither member can be ruled out, so v stays HasName | HasAge.
  4. On that union, age isn't common to every member — a plain HasName doesn't have it — so v.age is an error, even though this object clearly has an age. The type you declared wins over what you can see in the literal.

#Narrowing with typeof

JavaScript's typeof operator returns one of eight strings: "string", "number", "bigint", "boolean", "symbol", "undefined", "object" or "function". TypeScript recognises typeof x === "…" (and !==, and switch (typeof x)) as a type guard and narrows x to the matching type.

It's the right tool for primitives and for telling functions apart from other values. It can't tell one object type from another — every object, array, Date and null is just "object" — so for objects you use instanceof, in or a discriminant.

From my notes:

TypeScript
function formatInput(input: string | number | boolean): string {
  if (typeof input === "string") {
    return input.toUpperCase();
  } else if (typeof input === "number") {
    return input.toFixed(2);
  } else {
    return input ? "True" : "False";
  }
}

console.log(formatInput("hi")); // HI
console.log(formatInput(3.14159)); // 3.14
console.log(formatInput(false)); // False
What’s happening
  1. input starts as string | number | boolean.
  2. First check: inside it, input is string, so toUpperCase() is allowed. On the way past it, the checker removes string, leaving number | boolean.
  3. Second check: inside it, input is number, so toFixed(2) compiles. What's left is boolean.
  4. The final else needs no check at all — the checker already knows input is boolean, so the ternary is just testing true or false. This narrowing by elimination is what makes else branches useful.
  5. At runtime: "hi" → "HI"; 3.14159 → "3.14" (toFixed rounds and returns a string); false → "False".
AdvancedThe typeof null trap

The one trap with typeof is a famous JavaScript bug: typeof null is "object". TypeScript models that faithfully, so a typeof … === "object" check does not rule out null:

TypeScript
function count(items: string[] | null): number {
  if (typeof items === "object") {
    return items.length; // ❌ 'items' is possibly 'null'.
  }
  return 0;
}
What’s happening
  1. items is string[] | null. An array's typeof is "object" — and so is null's.
  2. So inside the if, the checker narrows items to string[] | null: both members pass the check, so nothing is removed.
  3. items.length fails with TS18047: "'items' is possibly 'null'." The compiler has caught a real bug — count(null) would throw "Cannot read properties of null".
  4. Fixes: check for null directly (if (items !== null)), use truthiness (if (items)), or use Array.isArray(items), which TypeScript also understands as a guard.
AdvancedEdge case: narrowing lost in callbacks

#JavaScript typeof vs TypeScript typeof

TypeScript has two different typeofs that share a keyword, and which one you get depends on where you write it:

  • In a value position (inside an expression), it's JavaScript's runtime operator. It runs when the code runs, returns a string like "object", and stays in the compiled output. This is the one that narrows.
  • In a type position (after a :, in a type X = …, inside <…>), it's TypeScript's type query: "give me the static type of this variable". It runs at compile time and is erased completely.
TypeScript
const config = {
  host: "localhost",
  port: 8080,
  secure: false,
};

// Type position: a type query, erased at compile time
type Config = typeof config;
// type Config = { host: string; port: number; secure: boolean; }

type Port = typeof config.port; // number — property paths are allowed

// Value position: the JavaScript operator, runs at runtime
const kind = typeof config;
console.log(kind); // object
What’s happening
  1. type Config = typeof config asks the compiler for the type it inferred for config. Because config is a mutable object, the literals widen: host is string, not "localhost".
  2. A type query accepts a variable name or a property path like config.port, but not arbitrary expressions — typeof getConfig() is a syntax error in a type position. For a function's result you write ReturnType<typeof getConfig>.
  3. const kind = typeof config is ordinary JavaScript. At runtime it evaluates to the string "object", which is what gets logged.
  4. TypeScript even types the runtime operator precisely: kind has the type "string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function".
  5. In the compiled JavaScript, the two type lines disappear and const kind = typeof config; is left exactly as written — the quickest way to tell which typeof you're looking at.
AdvancedJavaScript vs TypeScript typeof table
JavaScript typeofTypeScript typeof
Where it appearsinside an expressionin a type position
What it gives youa string such as "object"a type such as { host: string; … }
When it runsat runtimeat compile time, then erased
Typical usenarrowing a unionderiving a type from a value

The type query is the start of a lot of type-level tricks — deriving types from values instead of writing them twice. See typeof in type positions and keyof typeof.

AdvancedEdge case: typeof inside narrowed branches

#Narrowing with instanceof

x instanceof C is a runtime check: it walks x's prototype chain looking for C.prototype. TypeScript treats it as a type guard: in the true branch x is narrowed to the instance type of C, and in the else branch that type is removed from the union.

The important limit, from my notes: you can't use instanceof with types or interfaces, because they don't exist at runtime — there's no Animal.prototype to look for after compilation. It only works with classes (or old-style constructor functions), which are real values.

From my notes:

TypeScript
class Dog {
  bark() {
    console.log("Woof!");
  }
}
class Cat {
  meow() {
    console.log("Meow!");
  }
}

function makeNoise(animal: Dog | Cat) {
  if (animal instanceof Dog) {
    animal.bark();
  } else {
    animal.meow();
  }
}

makeNoise(new Dog()); // Woof!
makeNoise(new Cat()); // Meow!
What’s happening
  1. Dog and Cat are classes, so each is both a type (the instance shape) and a value (the constructor function that survives compilation).
  2. animal instanceof Dog runs at runtime and asks: is Dog.prototype somewhere in animal's prototype chain? For new Dog() it is.
  3. The checker narrows animal to Dog inside the if, so bark() is allowed. In the else, Dog is removed from Dog | Cat, leaving Cat, so meow() is allowed.
  4. Output: Woof! for the dog, Meow! for the cat.
Advancedinstanceof with an interface fails

Try it with an interface and the compiler stops you:

TypeScript
interface Animal {
  name: string;
}

function check(x: unknown) {
  if (x instanceof Animal) { // ❌ 'Animal' only refers to a type, but is being used as a value here.
    console.log("an animal");
  }
}
What’s happening
  1. Animal is an interface: it exists only for the type checker and is erased from the output.
  2. The right-hand side of instanceof is a runtime value, so the compiled JavaScript would be x instanceof Animal with no Animal defined anywhere — a ReferenceError.
  3. TypeScript catches this with TS2693: "'Animal' only refers to a type, but is being used as a value here."
  4. To narrow to an interface, check the data instead: in, a discriminant or a custom type guard.
Advancedinstanceof with Error and Date

The places you'll actually reach for instanceof are built-in classes like Error and Date:

TypeScript
function toMessage(err: unknown): string {
  if (err instanceof Error) return err.message;
  if (typeof err === "string") return err;
  return "Unknown error";
}

function toDate(input: Date | string | number): Date {
  return input instanceof Date ? input : new Date(input);
}

console.log(toMessage(new TypeError("bad input"))); // bad input
console.log(toDate("2026-01-15").getUTCFullYear()); // 2026
What’s happening
  1. toMessage takes unknown because that's what a catch variable is under strict mode (see unknown in catch clauses). unknown lets you do nothing until you narrow.
  2. err instanceof Error narrows to Error, so err.message is allowed. It also matches subclasses — TypeError, RangeError and your own class NotFoundError extends Error — because their prototype chains pass through Error.prototype.
  3. The typeof check handles code that does throw "oops". Anything else falls through to the default message.
  4. In toDate, the ternary's false branch has input narrowed to string | number (Date removed), and new Date() accepts both. "2026-01-15" becomes a Date, and getUTCFullYear() returns 2026.
AdvancedPitfall: structural values fail instanceof
AdvancedChoosing typeof, instanceof or in

When to use which: typeof for primitives and functions; instanceof for class instances (especially built-ins like Error, Date, Map); in or a discriminant for plain objects, interfaces and anything that came from JSON.

#Narrowing with in

"key" in obj is a runtime check that's true when obj has a property called key — its own or inherited from the prototype chain. TypeScript uses it to narrow a union of object types: in the true branch it keeps the members that declare that key; in the false branch it keeps the members that don't require it.

It's the simple, everyday way to tell interfaces apart, because it checks the data, not the type — so it works with interfaces, type aliases and plain objects from JSON, where instanceof can't.

TypeScript
interface Fish {
  swim(): void;
}
interface Bird {
  fly(): void;
}

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

move({ swim: () => console.log("splash") }); // splash
move({ fly: () => console.log("flap") }); // flap
What’s happening
  1. animal is Fish | Bird. Calling animal.swim() directly would fail, because Bird has no swim.
  2. "swim" in animal runs at runtime and checks whether the object has a swim property.
  3. The checker looks at each member: Fish declares swim, Bird doesn't. So inside the if, animal is Fish; in the else, it's Bird.
  4. The first call passes an object with swim, which takes the first branch and logs splash. The second has only fly and logs flap.
Advancedin with optional properties

Optional properties are the subtle case. A member with an optional swim? might or might not have it, so it ends up on both sides:

TypeScript
interface Fish {
  swim(): void;
}
interface Bird {
  fly(): void;
}
interface Human {
  swim?(): void;
  fly?(): void;
}

function move(animal: Fish | Bird | Human) {
  if ("swim" in animal) {
    // animal: Fish | Human
    animal.swim?.();
  } else {
    // animal: Bird | Human
    animal.fly?.();
  }
}
What’s happening
  1. Fish requires swim, Bird doesn't have it, and Human may have it.
  2. True branch: the object definitely has a swim key, which rules out Bird but not Human — so animal is Fish | Human.
  3. False branch: the object has no swim key, which rules out Fish (it requires one) but not Human (it's optional) — so animal is Bird | Human.
  4. Even in the true branch, animal.swim is still typed as possibly undefined because of Human. Plain animal.swim() fails with TS2722 "Cannot invoke an object which is possibly 'undefined'", hence the ?.().
Advancedin on unlisted keys since TypeScript 4.9

Since TypeScript 4.9, in also works on types that don't mention the key at all. That makes it the tool for picking apart unknown data:

TypeScript
function getName(data: unknown): string | undefined {
  if (typeof data === "object" && data !== null && "name" in data) {
    // data: object & Record<"name", unknown>
    if (typeof data.name === "string") {
      return data.name; // string
    }
  }
  return undefined;
}

console.log(getName(JSON.parse('{"name":"Ana"}'))); // Ana
console.log(getName(JSON.parse('{"name":42}'))); // undefined
What’s happening
  1. data starts as unknown. typeof data === "object" narrows it to object | null (the typeof null quirk again), and data !== null removes null, leaving object.
  2. "name" in data can't filter union members — there's only object — so instead TypeScript adds the property: data becomes object & Record<"name", unknown>. Now data.name is allowed, typed unknown.
  3. typeof data.name === "string" narrows the property access data.name to string. TypeScript narrows references like data.name, not just plain variables.
  4. The first call returns "Ana". In the second, name is 42, the inner check fails, and the function returns undefined.
AdvancedPitfall: overlapping members with in

#Custom type guards

Sometimes the built-in checks can't express the test you need, or you want to give a check a name and reuse it. A custom type guard (or user-defined type guard) is a function whose return type is a type predicate: paramName is SomeType.

At runtime it's just a function that returns a boolean. At compile time, the predicate links that boolean to the argument: when the call returns true, the argument is narrowed to SomeType at the call site; when it returns false, SomeType is removed from it. Think of it as teaching the compiler a new kind of typeof.

The example from my notes:

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

function isFish(animal: Fish | Bird): animal is Fish {
  return (animal as Fish).swim !== undefined;
}

function move(animal: Fish | Bird) {
  if (isFish(animal)) {
    animal.swim();
  } else {
    animal.fly();
  }
}
What’s happening
  1. animal is Fish replaces the usual boolean return type. The function still returns a plain boolean; the predicate is only information for the checker.
  2. In the body, animal.swim on its own wouldn't compile (Bird has no swim), so the notes use (animal as Fish).swim to read the property, then check it isn't undefined. ("swim" in animal does the same job without the assertion.)
  3. In move, if (isFish(animal)) narrows animal to Fish inside the if, so swim() compiles.
  4. In the else, the predicate works in reverse: Fish is removed from Fish | Bird, so animal is Bird and fly() compiles.
  5. If you changed the return type to boolean, the function would behave the same at runtime, but move would no longer compile: animal.swim() would be an error, because a plain boolean tells the checker nothing about animal.
AdvancedGuards at trust boundaries

A guard is most useful at a trust boundary — where data arrives as unknown, from JSON.parse, fetch, localStorage or a message event — because the check runs once and the narrowed type flows everywhere after it:

TypeScript
interface User {
  id: number;
  name: string;
}

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

const body: unknown = JSON.parse('{"id": 1, "name": "Ana"}');
if (isUser(body)) {
  console.log(body.name.toUpperCase()); // ANA
}

const rows: unknown[] = [{ id: 1, name: "Ana" }, { id: "2" }, null];
const users = rows.filter(isUser); // users: User[]
console.log(users.length); // 1
What’s happening
  1. Inside isUser, each && narrows a bit further: unknown → object (after the null check) → object & Record<"id", unknown> → id is a number → and the same for name. Every property access is backed by a check, so the body needs no as.
  2. JSON.parse returns any; annotating body as unknown forces a check before use. isUser(body) is true, so inside the if, body is User and body.name.toUpperCase() prints ANA.
  3. rows.filter(isUser) picks the overload of filter that takes a type predicate (filter<S extends T>(predicate: (value: T, …) => value is S): S[]), so the result is User[], not unknown[].
  4. At runtime, { id: "2" } fails the typeof value.id === "number" check and null fails the null check, so only Ana survives — users.length is 1.
AdvancedMore predicate forms beyond value is T

#Custom type guards can lie

The compiler trusts a type predicate. It checks that the body returns a boolean and that the predicate type fits the parameter — and that's it. It never checks whether your runtime logic actually proves the claim. A type guard is a type assertion wrapped in a function: if the body is wrong, the narrowing is wrong, and the compiler will confidently let you call things that don't exist.

There are two ways for a guard to lie, and they crash in different branches:

  • Too loose — returns true for values that aren't T. The crash is in the true branch.
  • Too strict — returns false for values that are T. The crash is in the else branch, because else narrows to "everything except T".

A too-loose guard: it says "this is a User" after checking only one of the two fields.

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

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

const data: unknown = JSON.parse('{"name":"Ana"}');

if (isUser(data)) {
  console.log(`Hello ${data.name}`); // Hello Ana
  console.log(data.email.toLowerCase()); // TypeError: Cannot read properties of undefined (reading 'toLowerCase')
}
What’s happening
  1. The file compiles with zero errors. isUser returns a boolean and User is assignable to unknown, which is all the checker verifies.
  2. At runtime, data is { name: "Ana" }. It's an object, it isn't null and it has name, so isUser returns true.
  3. The checker narrows data to User inside the if — a type that promises an email: string.
  4. data.name prints Hello Ana. Then data.email is undefined, and calling .toLowerCase() on it throws TypeError: Cannot read properties of undefined (reading 'toLowerCase'). I ran this with Node 26 (which runs .ts files directly by stripping the types), and that's the exact error.
  5. The bug isn't in the line that crashed — it's in the guard. Every function that trusts isUser has the same hole.
AdvancedA guard that's too strict

A too-strict guard: a typo inside the guard makes it return false for every fish, and the else branch is the one that blows up.

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

function isFish(animal: Fish | Bird): animal is Fish {
  return "swin" in animal; // typo: should be "swim"
}

function move(animal: Fish | Bird) {
  if (isFish(animal)) {
    animal.swim();
  } else {
    animal.fly();
  }
}

move({ fly: () => console.log("flap flap") }); // flap flap
move({ swim: () => console.log("splash") }); // TypeError: animal.fly is not a function
What’s happening
  1. Again, no compile errors. "swin" in animal is a valid boolean expression — in accepts any string — so the typo goes unnoticed.
  2. The bird call: "swin" in bird is false, the else branch runs, the checker says animal is Bird, and that happens to be true. Prints flap flap.
  3. The fish call: "swin" in fish is also false. The else branch narrows animal to Bird — "not a Fish, so it must be the other one" — but it's really a fish.
  4. animal.fly is undefined, and calling it throws TypeError: animal.fly is not a function (again, the real Node output).
  5. This is the lie people forget: a predicate makes a claim about both outcomes. Returning false asserts "definitely not a Fish".
AdvancedA guard that checks nothing

The extreme case compiles just as happily — the body doesn't need to check anything:

TypeScript
function isString(value: unknown): value is string {
  return true; // compiles — nothing compares this to the claim
}
AdvancedInferred type predicates (TypeScript 5.5)

Since TypeScript 5.5 the compiler writes the predicate for you when it can prove one from the function body. An inferred predicate can't lie, because the compiler derived it from the code.

TypeScript
const values = [1, null, 2, undefined, 3]; // (number | null | undefined)[]

const a = values.filter((x) => x !== null); // (number | undefined)[]
const b = values.filter((x) => x != null); // number[]
const c = values.filter((x) => !!x); // (number | null | undefined)[] — no predicate inferred

const isNumber = (x: unknown) => typeof x === "number";
// const isNumber: (x: unknown) => x is number
What’s happening
  1. TypeScript infers a predicate for a function with no declared return type, a single return (or an arrow expression body), and a parameter it doesn't reassign.
  2. x => x !== null is inferred as x is number | undefined, so a is (number | undefined)[]. Before 5.5, all three results were (number | null | undefined)[] and you had to write the predicate yourself.
  3. x => x != null removes both null and undefined, so it becomes x is number and b is number[].
  4. x => !!x gets no predicate. The rule is "if and only if": true must mean "is T" and false must mean "is not T". A false here could be 0, which is a number, so false doesn't prove "not a number" — and TypeScript refuses to infer a lie.
  5. isNumber gets the predicate x is number in its type, so it works as a guard anywhere, just like a hand-written one.

How to keep guards honest:

  • Prefer checks the compiler understands directly (typeof, in, a discriminant), or let TypeScript 5.5+ infer the predicate.
  • When you must write one, check every property you claim, and keep the guard next to the type it describes.
  • For data from outside (APIs, files), use a schema library like zod, where the type is derived from the runtime check, so they can't drift apart (see Validating runtime data).
  • Unit-test guards with negative cases — values that should return false.

#Assertion functions

Added

An assertion function is the throwing cousin of a type guard. Instead of returning true or false, it throws if the check fails. If it returns normally, the compiler narrows the argument for the rest of the scope — no if block needed. They were added in TypeScript 3.7 and come in two forms:

  • asserts condition — "if I return, the expression you passed me was truthy", so whatever that expression narrows, stays narrowed.
  • asserts value is T — "if I return, value is a T".

They fit code that should simply stop when an assumption is broken: validating config at startup, invariants inside a function, and tests. Node's own assert is typed this way.

TypeScript
function assert(condition: unknown, message = "Assertion failed"): asserts condition {
  if (!condition) throw new Error(message);
}

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

function greet(user: { name: string } | null, input: unknown): string {
  assert(user !== null, "No user");
  // user: { name: string } from here to the end of the function
  assertIsString(input);
  // input: string
  return `${user.name}: ${input.toUpperCase()}`;
}

console.log(greet({ name: "Ana" }, "hi")); // Ana: HI
greet(null, "hi"); // throws Error: No user
What’s happening
  1. asserts condition declares: "if this function returns, condition was truthy". The return type is effectively void; the function either returns nothing or throws.
  2. In greet, assert(user !== null, …) is treated like if (!(user !== null)) throw …. Execution can only reach the next line if user !== null, so from there to the end of the function user is { name: string }.
  3. assertIsString(input) uses the other form, asserts value is string. After the call, input is narrowed from unknown to string, which is why input.toUpperCase() compiles.
  4. greet({ name: "Ana" }, "hi") passes both checks and returns "Ana: HI". greet(null, "hi") throws Error: No user at the first assertion, before anything touches user.name.
  5. Like type guards, assertion functions are not verified: if the body forgot to throw, the narrowing would be a lie. The only check is that you don't return a value — return true gives "Type 'boolean' is not assignable to type 'void'".
AdvancedAssertion calls need explicit annotations

There's one rule that catches everyone: the compiler must be able to see that a call is an assertion without inferring anything. An arrow function stored in a const without a type annotation doesn't qualify:

TypeScript
const assertIsNumber = (value: unknown): asserts value is number => {
  if (typeof value !== "number") throw new TypeError("Not a number");
};

declare const input: unknown;
assertIsNumber(input); // ❌ Assertions require every name in the call target to be declared with an explicit type annotation.
What’s happening
  1. The arrow function itself is fine — it has an asserts return type.
  2. But the variable assertIsNumber has no annotation; its type would have to be inferred from the arrow function.
  3. Control flow analysis builds its graph of narrowing points before types are fully inferred. To decide whether a call can narrow, the checker needs the callee's type straight from a declaration, without inference (doing otherwise could go circular). So it reports TS2775: "Assertions require every name in the call target to be declared with an explicit type annotation."
  4. Fixes: use a function declaration (as in the first example), or annotate the variable: const assertIsNumber: (value: unknown) => asserts value is number = (value) => { … }.
AdvancedNode's assert as an assertion function

With Node's built-in assert, typed in @types/node as asserts value:

TypeScript
import assert from "node:assert";

function parsePort(raw: string | undefined): number {
  assert(raw, "PORT is not set"); // raw: string after this line
  const port = Number(raw);
  assert(Number.isInteger(port) && port > 0, `Invalid port: ${raw}`);
  return port;
}

console.log(parsePort("8080")); // 8080
What’s happening
  1. raw starts as string | undefined. assert(raw, …) throws an AssertionError with the message PORT is not set if raw is falsy (undefined or "").
  2. After it returns, raw is narrowed to string — the same as after if (!raw) throw ….
  3. The second assert doesn't narrow any types (port is already number); it's a plain runtime invariant that Number("abc") (NaN) or "-1" would fail.
  4. Compare with the non-null assertion raw!: that only silences the compiler. assert actually checks at runtime and fails loudly at the line that's wrong.
AdvancedType guard vs assertion function table
Type guardAssertion function
Return typevalue is Tasserts value is T / asserts condition
At runtimereturns true / falsereturns nothing, or throws
Narrowing appliesinside the if (and else)to everything after the call
Use whenboth outcomes are normalfailure means stop
Checked by the compiler?no — it can lieno — it can lie

#Discriminated unions

A discriminated union (also called a tagged union) is a union of object types where every member has one common property — the discriminant or tag — with a different literal type in each: kind: "circle", kind: "square", and so on. Checking that one property tells TypeScript which member you have, so it narrows the whole object. My notes put it as "1 common property in all of them to differentiate them".

It's the most reliable way to narrow objects. It doesn't depend on keys happening to be different (like in), it doesn't need classes (like instanceof), and it works with plain JSON from an API. The mental model: every envelope has a label printed on the outside, so you read the label instead of guessing from the contents.

The shapes example from my notes:

TypeScript
interface Circle {
  kind: "circle";
  radius: number;
}
interface Square {
  kind: "square";
  sideLength: number;
}
interface Rectangle {
  kind: "rectangle";
  width: number;
  height: number;
}
type Shape = Circle | Square | Rectangle;

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    case "rectangle":
      return shape.width * shape.height;
    default:
      const _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
  }
}

console.log(getArea({ kind: "circle", radius: 1 })); // 3.141592653589793
console.log(getArea({ kind: "rectangle", width: 2, height: 3 })); // 6
What’s happening
  1. Each interface has a kind property whose type is a single string literal. shape.kind on the union has the type "circle" | "square" | "rectangle", so the compiler knows exactly which tags exist.
  2. switch (shape.kind) with case "circle": the checker keeps only the members whose kind can be "circle", so shape is Circle and shape.radius compiles. Without the switch, shape.radius would fail with "Property 'radius' does not exist on type 'Shape'".
  3. case "square" narrows to Square; case "rectangle" narrows to Rectangle, where width and height exist.
  4. In default, every member has been handled and removed, so shape is never. Assigning it to a never variable compiles only because nothing is left — that's the exhaustiveness check covered in the next topic.
  5. Runtime: the circle gives Math.PI * 1 ** 2 = 3.141592653589793; the rectangle gives 2 * 3 = 6.
AdvancedBag of optional properties, for comparison

Compare the "bag of optional properties" version that discriminated unions replace:

TypeScript
interface LooseShape {
  kind: "circle" | "square";
  radius?: number;
  sideLength?: number;
}

function area(shape: LooseShape): number {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius ** 2; // ❌ 'shape.radius' is possibly 'undefined'.
  }
  return 0;
}
What’s happening
  1. LooseShape is a single object type, not a union. Its kind and its optional properties aren't linked to each other in any way.
  2. So checking kind === "circle" narrows shape.kind, but tells the checker nothing about radius — it's still number | undefined.
  3. Result: TS18048, "'shape.radius' is possibly 'undefined'." You'd need a ! or an extra check, and the type would also allow nonsense like { kind: "circle", sideLength: 5 }.
  4. The union version makes impossible states unrepresentable: a Circle must have a radius and can't have a sideLength. That's the real reason to model data this way.
AdvancedRequest state as a discriminated union

The pattern you'll use most in front-end code — request state:

TypeScript
type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; error: Error };

function render(state: RequestState): string {
  if (state.status === "loading") return "Loading…";
  if (state.status === "error") return `Failed: ${state.error.message}`;
  if (state.status === "success") return state.data.join(", ");
  return "Click to load"; // state: { status: "idle" }
}

console.log(render({ status: "success", data: ["a", "b"] })); // a, b
console.log(render({ status: "idle" })); // Click to load
What’s happening
  1. Four states, each with exactly the data that makes sense for it: data only exists on success, error only on error. There's no way to build a "loading with an error" state.
  2. Each if narrows and eliminates. After the loading check returns, state is one of the three others; after error, two; after success, only { status: "idle" } is left.
  3. state.error.message and state.data.join(", ") compile only inside their own checks — try state.data before the success check and you get "Property 'data' does not exist on type …".
  4. The success call joins ["a", "b"] into "a, b"; the idle call falls through to the last line.
AdvancedValid discriminants and indirect narrowing

What can be a discriminant: a property whose type in each member is a literal type — string, number or boolean literals, enum members, null or undefined. A Result type often uses a boolean: { ok: true; value: T } | { ok: false; error: string }, and if (result.ok) narrows it.

Narrowing also follows the discriminant through a few indirections (TypeScript 4.4+):

TypeScript
type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; error: Error };

function describe(state: RequestState): string {
  const { status } = state;
  if (status === "success") {
    return `${state.data.length} items`; // narrowed via the destructured const
  }

  const isError = state.status === "error";
  if (isError) {
    return state.error.message; // narrowed via the saved condition
  }

  return status;
}
What’s happening
  1. const { status } = state copies the tag into a const. Because both status and state are never reassigned, the checker remembers where status came from, and status === "success" narrows state to the success member.
  2. const isError = state.status === "error" saves a condition in a const. if (isError) then narrows state exactly as if the comparison were written inline (an aliased condition).
  3. Both tricks stop working if state (or the alias) is a let that's reassigned — the checker can no longer be sure they still describe the same object.
AdvancedPitfall: widened discriminant tags

#Exhaustiveness checking with never

Discriminated unions grow: you add a Triangle to Shape, and every switch over shape.kind now has a case it doesn't handle. Exhaustiveness checking makes the compiler find all of them for you. My notes flag this one as important, and it's a favourite interview follow-up.

The trick uses the fact that narrowing removes a member for each case you handle. After the last case, nothing is left, so the variable's type is never — the empty type. And only never is assignable to never. So if you assign the leftover to a never-typed variable, it compiles only when every case is handled; if one is missing, the leftover isn't empty, and the error names exactly what you forgot.

Add Triangle to the shapes from the previous topic without updating the switch:

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

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    case "rectangle":
      return shape.width * shape.height;
    default:
      const _exhaustiveCheck: never = shape; // ❌ Type 'Triangle' is not assignable to type 'never'.
      return _exhaustiveCheck;
  }
}
What’s happening
  1. shape starts as Circle | Square | Rectangle | Triangle.
  2. Each case removes its member on the way past: after "circle", "square" and "rectangle", the default branch is reached only when kind is something else — and the only thing left is Triangle.
  3. const _exhaustiveCheck: never = shape tries to put a Triangle into a never. That fails with TS2322: "Type 'Triangle' is not assignable to type 'never'." The error sits in the default of this exact switch, and names the missing member.
  4. Add case "triangle": return 0.5 * shape.base * shape.height; and the default sees never again, so the assignment compiles.
  5. Every switch over Shape that has this check lights up the moment you add Triangle. Without it, the function would compile and silently return undefined for triangles.
AdvancedExhaustiveness via a throwing assertNever

The cleaner version my notes call "much better" pulls the check into a helper. It also throws at runtime, which matters when data from outside breaks the types:

TypeScript
interface Circle {
  kind: "circle";
  radius: number;
}
interface Square {
  kind: "square";
  sideLength: number;
}
type Shape = Circle | Square;

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 "square":
      return shape.sideLength ** 2;
    default:
      return assertNever(shape);
  }
}

const fromServer = JSON.parse('{"kind":"hexagon","side":2}') as Shape;
getArea(fromServer); // throws Error: Unhandled value: {"kind":"hexagon","side":2}
What’s happening
  1. assertNever has a parameter of type never, so you can only call it with something the checker has narrowed to nothing. With all cases handled, shape is never in the default, so assertNever(shape) compiles.
  2. Forget a case and the call fails instead: "Argument of type 'Triangle' is not assignable to parameter of type 'never'." Same information as the const version, in one reusable line.
  3. Its return type is also never (it always throws), so return assertNever(shape) satisfies the function's number return type.
  4. Types are erased, though, and JSON.parse returns whatever the server sent. Here the as Shape lies — the object is a "hexagon" — so neither case matches and default really runs at runtime.
  5. assertNever throws Error: Unhandled value: {"kind":"hexagon","side":2}. Without it the function would return undefined, which becomes NaN somewhere far away. The helper gives you a compile-time check and a loud runtime failure.
Advancedsatisfies never one-liner

Since TypeScript 4.9 there's also a one-liner that needs no helper and no variable — but it doesn't throw at runtime:

TypeScript
type Shape = { kind: "circle"; radius: number } | { kind: "square"; sideLength: number };

function describe(shape: Shape): string {
  switch (shape.kind) {
    case "circle":
      return "round";
    case "square":
      return "boxy";
    default:
      shape satisfies never; // with a case missing: "Type '…' does not satisfy the expected type 'never'."
      return "unknown";
  }
}
What’s happening
  1. satisfies checks that an expression fits a type without changing its type (see satisfies). Here it checks that shape fits never, which is only true when every case has been handled.
  2. Remove the "square" case and you'd get TS1360: Type '{ kind: "square"; sideLength: number; }' does not satisfy the expected type 'never'.
  3. At runtime satisfies is erased, so the default just returns "unknown" for unexpected data. Use assertNever when you want a throw.
AdvancedWhen a default case isn't needed

You don't always need a default

My notes point out that a default isn't always required. If the function returns a value and its return type doesn't include undefined, TypeScript already checks that every path returns:

TypeScript
interface Circle {
  kind: "circle";
  radius: number;
}
interface Square {
  kind: "square";
  sideLength: number;
}
interface Triangle {
  kind: "triangle";
  base: number;
  height: number;
}
type Shape = Circle | Square | Triangle;

function getPerimeter(shape: Shape): number { // ❌ Function lacks ending return statement and return type does not include 'undefined'.
  switch (shape.kind) {
    case "circle":
      return 2 * Math.PI * shape.radius;
    case "square":
      return 4 * shape.sideLength;
  }
}
What’s happening
  1. The switch handles "circle" and "square" but not "triangle". If shape is a triangle, execution falls off the end of the function and returns undefined.
  2. The declared return type is number, so TypeScript reports TS2366 on the signature: "Function lacks ending return statement and return type does not include 'undefined'."
  3. Add the "triangle" case and the error goes away without a default: after the last case shape is never, so the checker knows the end of the function is unreachable.
  4. The downside: the message doesn't name the missing case, and it sits on the function signature instead of the switch.
  5. It also only works when there's a return type to violate. A function returning void, or one whose return type includes undefined, gets no check at all — that's when you want assertNever.
Does this compile?
TypeScript
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; sideLength: number }
  | { kind: "rectangle"; width: number; height: number };

function logShape(shape: Shape): void {
  switch (shape.kind) {
    case "circle":
      console.log("circle");
      break;
    case "square":
      console.log("square");
      break;
  }
}
Show answer

Yes — it compiles with no errors, and a rectangle silently logs nothing.

What’s happening
  1. The switch doesn't handle "rectangle", so for a rectangle execution simply skips the switch and reaches the end of the function.
  2. The return type is void, so falling off the end is fine — there's no "lacks ending return statement" check to trip.
  3. Nothing else asks for exhaustiveness: no default, no never assignment. Narrowing happened, but nothing used the leftover { kind: "rectangle"; … }.
  4. Add default: assertNever(shape); (or shape satisfies never;) and you get Argument of type '{ kind: "rectangle"; width: number; height: number; }' is not assignable to parameter of type 'never'. Exhaustiveness is opt-in: the compiler only checks it when you ask.

Built from Rohit’s “Typescript Interview” study doc. Examples are checked with the TypeScript compiler in strict mode.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.