Lesson 11 of 20

Type Guards

typeof, and Where JavaScript Lies to You

A type guard is any check that tells TypeScript which member of a union it is holding. The compiler reads your ordinary JavaScript conditions and narrows the type inside the branch they control — no special syntax required for the common cases.

typeof is the workhorse for primitives, and TypeScript understands all seven of its results. What it also understands, and you must remember, is that typeof has two famous quirks. typeof null is "object", a bug preserved from JavaScript's first weeks for compatibility. And an array is also "object", since arrays are objects.

TypeScript models both quirks faithfully, which produces errors that look wrong until you see why. Narrow an unknown with typeof value === "object" and the result still includes null, so reading a property is still an error. Add an explicit value !== null and it clears. For arrays, use Array.isArray, which is a proper guard and narrows correctly.

Example
function describe(value: unknown): string {
  if (typeof value === "string") return value.toUpperCase();
  if (typeof value === "number") return value.toFixed(2);
  if (typeof value === "boolean") return value ? "yes" : "no";
  if (typeof value === "function") return "a function";
  return "something else";
}

// The null trap
function keysOf(value: unknown): string[] {
  if (typeof value === "object") {
    // return Object.keys(value);
    // ERROR: value is 'object | null' — typeof null is "object"
  }

  if (typeof value === "object" && value !== null) {
    return Object.keys(value);   // now it is just object
  }
  return [];
}

// Arrays need their own check
function count(value: unknown): number {
  if (Array.isArray(value)) return value.length;
  return 0;
}
Notes
  • typeof NaN is "number", so a typeof guard will happily let NaN through as a valid number. If a value must be a real number, check with Number.isFinite as well.

Truthiness and Equality Narrowing

Writing if (value) narrows away everything falsy, which for a string | null leaves plain string. It is the shortest guard there is and it is used constantly. It is also the one that hides a real bug.

JavaScript's falsy values include 0, the empty string, and NaN alongside null and undefined. So if (count) on a number | undefined treats a genuine zero as "missing", and if (name) treats an empty string the same way. Both are common sources of quietly wrong behaviour: a cart showing the default quantity instead of zero, a saved blank field silently reverting to a placeholder.

The fix is to say what you mean. Compare against undefined or null explicitly when that is the case you care about. TypeScript narrows equality checks just as well as truthiness ones, and it understands that != null with loose equality removes both null and undefined — the one place where the loose operator is genuinely the clearest choice.

Equality narrowing also works between two union-typed values: comparing them narrows both sides to the types they could have in common, which is occasionally exactly what you need.

Example
function greet(name: string | null) {
  if (name) {
    console.log(name.toUpperCase());   // string
  }
}

// The bug: 0 is falsy
function showQuantity(qty: number | undefined) {
  if (qty) console.log(`Qty: ${qty}`);
  // A real order of 0 items prints nothing at all
}

// Say what you mean
function showQuantityFixed(qty: number | undefined) {
  if (qty !== undefined) console.log(`Qty: ${qty}`);   // qty is number
}

// != null removes null AND undefined in one check
function label(value: string | null | undefined): string {
  if (value != null) return value.trim();   // value is string
  return "(none)";
}
Notes
  • The same trap applies to || for defaults, which is why ?? exists. qty || 10 replaces a real zero; qty ?? 10 does not.

in and instanceof

Two more built-in guards handle objects. The in operator checks whether a property name exists on an object, and TypeScript uses that to pick the union member that declares it. This is the natural way to narrow a union of shapes that has no shared discriminant property — although if you control the types, adding a discriminant is better still, because in only tells you a property exists, not that the object is really what you think.

instanceof checks an object against a class, and narrows to that class. It is the right guard for anything built with new: Error, Date, Map, your own classes. Error handling is where you will use it most, since a catch variable is unknown under strict and instanceof Error is how you earn the right to read .message.

There is a hard limit that follows directly from type erasure: instanceof cannot be used with an interface or a type alias. Those do not exist at runtime, so there is nothing for the operator to compare against. value instanceof User where User is an interface is a compile error, and this is the moment many learners finally internalise that types are not real. When you need to check a plain object's shape, you write the check yourself — which is the next section.

Example
interface Fish { swim(): void }
interface Bird { fly(): void }

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

// instanceof works on classes, which exist at runtime
function whenOf(value: Date | string): string {
  if (value instanceof Date) return value.toISOString();
  return value;
}

// The everyday case: a catch block
try {
  await loadUser();
} catch (err) {
  if (err instanceof Error) console.error(err.message);
  else console.error("Non-Error thrown:", err);
}

// Not possible — an interface has no runtime existence
// if (value instanceof Fish) { }
// ERROR: 'Fish' only refers to a type, but is being used as a value here.
Notes
  • in is a runtime property check, so it also sees inherited properties from the prototype chain. For narrowing your own data shapes it is fine; for anything security-sensitive, prefer Object.hasOwn and an explicit validation function.

Writing Your Own Type Guards

When the built-in checks are not enough, you can write a function whose return type is a type predicate: value is Student instead of boolean. The function still returns a boolean at runtime; the predicate simply tells the compiler what a true result proves.

The rule to burn into your memory is that TypeScript does not verify the body. It checks that the parameter and the asserted type are related, and then it trusts you completely. A guard that returns true unconditionally will silently declare every value to be a Student, and every error that follows will point somewhere else. A type predicate is a promise of exactly the same kind as an as assertion — the difference is that the promise is written once, in a function you can test, instead of scattered across every call site.

So write guards carefully and check every property you claim. If a shape has three required fields, check three fields — a guard that only tests one and asserts the whole interface is how bad data gets in while looking safe.

Type guards compose well with array methods, which is where they earn their keep. list.filter(isStudent) gives you a Student[] rather than a (Student | null)[], because filter is typed to understand predicates.

Example
interface Student {
  id: number;
  name: string;
  marks: number;
}

// A careful guard: every claimed property is checked
function isStudent(value: unknown): value is Student {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value && typeof value.id === "number" &&
    "name" in value && typeof value.name === "string" &&
    "marks" in value && typeof value.marks === "number"
  );
}

const raw: unknown = JSON.parse('{"id":1,"name":"Ananya","marks":88}');

if (isStudent(raw)) {
  console.log(raw.name);   // raw is Student — and it really is, because we checked
}

// filter understands predicates, so the result type narrows too
const maybe: (Student | null)[] = [];

function isPresent(value: Student | null): value is Student {
  return value !== null;
}

const present: Student[] = maybe.filter(isPresent);

// A lying guard. This compiles. Do not do it.
function isStudentBad(value: unknown): value is Student {
  return true;
}
Notes
  • Naming convention: start guards with is or has. It reads well at the call site and signals to the next reader that the function's correctness matters more than usual.

Assertion Functions

A close relative of the type guard is the assertion function, written with asserts value is Student as its return type. Instead of returning a boolean for you to test, it throws when the check fails — and if it returns at all, TypeScript narrows the value from that line onwards for the rest of the scope.

This suits the cases where a failure is not a branch you want to handle but a bug you want to stop: configuration that must be present at startup, an environment variable that must exist, an invariant that should never be false. The code that follows reads as if the value were always the right type, because if it were not, execution would already have stopped.

Two rules to know. The function must have an explicit return-type annotation — TypeScript will not infer asserts — and the same applies if you store one in a variable, which needs an explicit type annotation on the variable too. And, exactly as with type predicates, the compiler does not check that your body actually throws in the failing case. The asserts keyword is another promise you are making.

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

function slugify(input: unknown): string {
  assertIsString(input);
  return input.trim().toLowerCase().replace(/\s+/g, "-");   // input is string
}

// The startup-configuration case
function assertDefined<T>(
  value: T | undefined,
  name: string
): asserts value is T {
  if (value === undefined) {
    throw new Error(`Missing required configuration: ${name}`);
  }
}

const apiUrl = process.env.API_URL;
assertDefined(apiUrl, "API_URL");
console.log(apiUrl.toUpperCase());   // string from here on
Notes
  • Use an assertion function when a failure means the program cannot continue. Use a type predicate when you want to handle both outcomes. Mixing them up gives you either crashes where you wanted a fallback, or silent fallbacks where you wanted a crash.

Guarding the Real Boundary: Data From Outside

Everything in this lesson exists for one purpose: making the untyped outside world safe to use inside your typed program. The boundary is wherever data enters — a fetch response, localStorage, a query string, a form submission, a message from another window, a file you read. On the outside of that line, the honest type is unknown. On the inside, it is a real type, because you checked.

The pattern is always the same three steps. Type the incoming value as unknown, not any and not an optimistic interface. Run a guard. Work with the narrowed type from there on. It is a small amount of extra code at exactly one place per boundary, and it converts a whole category of production crashes into a handled error path.

Writing those guards by hand for every shape becomes tedious once your API has more than a few endpoints, and that is what schema validation libraries are for. Zod is the widely used option: you describe the shape once, it validates at runtime, and it derives the TypeScript type from the same description — so the type and the check can never drift apart. That is the missing half of type safety, and it is worth adopting as soon as you are past the basics.

Whichever route you take, the principle stands. TypeScript checks what you wrote; only a runtime check can tell you what actually arrived.

Example
interface Student {
  id: number;
  name: string;
  marks: number;
}

// the guard from the previous section
declare function isStudent(value: unknown): value is Student;

// The three steps, at every boundary
async function loadStudent(id: number): Promise<Student> {
  const res = await fetch(`/api/students/${id}`);
  const data: unknown = await res.json();      // 1. unknown, not Student

  if (!isStudent(data)) {                      // 2. guard
    throw new Error("Unexpected response shape from /api/students");
  }

  return data;                                 // 3. narrowed and safe
}

// localStorage is another boundary people forget
function loadDraft(): Student | null {
  const text = localStorage.getItem("draft");
  if (text === null) return null;

  const parsed: unknown = JSON.parse(text);
  return isStudent(parsed) ? parsed : null;
}
Notes
  • Compare this with the very first lesson's return res.json(), which compiled cleanly and promised something it could not deliver. The difference between the two is the whole practical value of this lesson.
Ask AI