QA Interview Hub · Knowledge base · Quizzes

TypeScript

Type narrowing and type guards

Basic narrowing: typeof, instanceof, in, truthiness

Narrowing is when TS, after a check, understands that a variable has a narrower type. The compiler analyses the control flow: if, switch, an early return, throw.

function formatAmount(value: string | number): string {
  if (typeof value === 'number') {
    return value.toFixed(2); // here value: number
  }
  return value.trim(); // here value: string
}

The main tools:

  • typeof is for primitives: 'string', 'number', 'boolean', 'undefined', 'object', 'function', 'bigint', 'symbol'. A trap: typeof null === 'object', so for objects I always add !== null.
  • instanceof is for classes: if (err instanceof Error). In catch the variable has type unknown (with useUnknownInCatchVariables, part of strict), so this is a common pattern.
  • in checks that a field exists: if ('errors' in body).
  • Truthiness is if (value). Careful: it filters out both 0 and an empty string. For a transfer amount 0 is a valid value that you can lose this way.
  • Equality: if (status === 'FAILED') narrows a union of literals.
try {
  await client.createTransfer(payload);
} catch (err) {
  if (err instanceof Error) {
    console.log(err.message);
  } else {
    console.log('Non-error thrown', err);
  }
}

Narrowing also works through an early exit: after if (!account) throw new Error('not found') the variable account is no longer undefined.

In an interview: Narrowing is when, after a typeof, instanceof, in check or a comparison, TS itself understands a more precise type. I remember the traps: typeof null gives 'object', and a truthy check filters out zero, which is critical for money.

Discriminated unions

A discriminated union is a union of types with a common literal field (the discriminant). Checking this field narrows the whole object. This is the cleanest way to describe responses that come in different shapes.

type ApiResult<T> =
  | { status: 'success'; data: T }
  | { status: 'error'; error: { code: string; message: string } };

function unwrap<T>(res: ApiResult<T>): T {
  if (res.status === 'error') {
    throw new Error(`${res.error.code}: ${res.error.message}`);
  }
  return res.data; // here res is the success branch
}

A classic fintech example is payment methods or events:

type Payment =
  | { kind: 'card'; pan: string; expiry: string }
  | { kind: 'iban'; iban: string }
  | { kind: 'wallet'; walletId: string };

function describe(p: Payment): string {
  switch (p.kind) {
    case 'card': return `Card *${p.pan.slice(-4)}`;
    case 'iban': return `IBAN ${p.iban}`;
    case 'wallet': return `Wallet ${p.walletId}`;
  }
}

If a function has a return type and the switch doesn't cover all the variants, under strict the compiler will say the function may return undefined. For explicitness I add a default branch with never.

Why this is better than one type with a pile of optional fields: with { pan?: string; iban?: string; walletId?: string } you can create a meaningless object with none of the fields or with all of them at once. A union makes impossible states unrepresentable.

In an interview: A discriminated union is a union with a common field such as status or kind, and checking that field narrows the whole object. I describe success or error responses and different payment types this way, so that invalid combinations of fields simply don't compile.

User-defined type guards and assertion functions

When the built-in checks are not enough, you write your own type guard: a function with the return type value is T. If it returns true, TS narrows the variable to T.

interface Account { id: string; balance: number; currency: string }

function isAccount(value: unknown): value is Account {
  return (
    typeof value === 'object' && value !== null &&
    typeof (value as Account).id === 'string' &&
    typeof (value as Account).balance === 'number'
  );
}

const body: unknown = await res.json();
if (!isAccount(body)) throw new Error('Unexpected account shape');
body.balance; // Account

Important: the compiler doesn't verify that the guard's body is honest. If a guard returns true for a wrong object, you get a lying type. So complex checks are better generated from a schema.

An assertion function is a function with the signature asserts value is T. It returns nothing and throws an error if the condition isn't met. After the call the type is narrowed.

function assertDefined<T>(v: T, msg: string): asserts v is NonNullable<T> {
  if (v === undefined || v === null) throw new Error(msg);
}

const row = rows.find(r => r.txId === txId);
assertDefined(row, `Transaction ${txId} not found`);
row.amount; // without '?' and without '!'

In practice, for validating API responses I use a schema library, for example zod: schema.parse(body) checks data at runtime and returns a value with the right type, and the type is derived from the schema through z.infer. That way the type and the check don't drift apart.

In an interview: A type guard is a function with value is T, after which TS narrows the type, and an assertion function with asserts throws an error and narrows the type further down the code. The compiler doesn't check their logic, so for API responses I prefer validation with a schema from which the type is derived.