Skip to content

Structural Types and Narrowing

TypeScript usually compares values by their visible structure, not by a declared nominal identity. If a value has the required members with compatible types, it fits:

ts
type Named = { name: string };

class Customer {
  constructor(
    public name: string,
    public accountNumber: string,
  ) {}
}

const raw = { name: "Amina", source: "import" };

function greet(value: Named) {
  return `Hello, ${value.name}`;
}

greet(new Customer("Lee", "C-42"));
greet(raw);

Neither value declares conformance to Named. Both possess a compatible name. This works naturally with JavaScript's object conventions and keeps functions coupled to the smallest shape they need.

Structural compatibility does not mean all extra properties are always accepted. A fresh object literal receives an excess-property check:

ts
greet({ name: "Amina", source: "import" }); // error: source is unexpected

Assigning the same object to a variable first is allowed, as above. The special check catches likely typos at construction sites; it is not a general “exact object” type system.

Unions model alternatives

ts
type Identifier = string | number;

function formatIdentifier(id: Identifier) {
  return typeof id === "number" ? id.toFixed(0) : id.toUpperCase();
}

Before the branch, only operations valid for both types are available. The typeof test narrows each branch. TypeScript also understands equality, truthiness, instanceof, property existence, and several array methods.

Be careful with truthiness when empty strings or zero are valid:

ts
function describeCount(count: number | undefined) {
  if (count !== undefined) {
    return `${count} records`; // includes zero
  }
  return "unknown count";
}

Narrowing follows control flow, including early returns:

ts
function normalize(value: string | null) {
  if (value === null) return "";
  return value.trim(); // value is string
}

Discriminated unions make states explicit

A shared literal property lets TypeScript connect a state with its data:

ts
type LoadState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; value: T }
  | { status: "failure"; error: Error };

function describe<T>(state: LoadState<T>): string {
  switch (state.status) {
    case "idle":
      return "Not started";
    case "loading":
      return "Loading";
    case "success":
      return `Loaded: ${String(state.value)}`;
    case "failure":
      return `Failed: ${state.error.message}`;
  }
}

This is stronger than an object containing unrelated isLoading, data?, and error? properties, which permits contradictory combinations.

An exhaustive check makes future variants visible:

ts
function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${String(value)}`);
}

function icon(state: LoadState<unknown>) {
  switch (state.status) {
    case "idle": return "○";
    case "loading": return "…";
    case "success": return "✓";
    case "failure": return "!";
    default: return assertNever(state);
  }
}

If a new union member is added, state is no longer never in the default branch.

User-defined guards package reusable evidence

ts
function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isTransaction(value: unknown): value is Transaction {
  return isRecord(value)
    && typeof value.id === "string"
    && typeof value.amount === "number";
}

The return type value is Transaction tells the checker what a true result means. TypeScript trusts that declaration, so an incorrect implementation is an unsafe assertion in function form. Validators that also need detailed diagnostics often return a parsed value or structured result instead.

An assertion function throws on failure and narrows afterward:

ts
function assertTransaction(value: unknown): asserts value is Transaction {
  if (!isTransaction(value)) {
    throw new Error("Invalid transaction");
  }
}

Property relationships can be derived

keyof creates a union of property keys:

ts
type Transaction = { id: string; amount: number; settled: boolean };
type TransactionKey = keyof Transaction; // "id" | "amount" | "settled"

Indexed access selects a property's type:

ts
type Amount = Transaction["amount"]; // number

Mapped types transform each property:

ts
type Optional<T> = {
  [Key in keyof T]?: T[Key];
};

TypeScript ships common utilities such as Partial<T>, Required<T>, Readonly<T>, Pick<T, Keys>, Omit<T, Keys>, and Record<Keys, Value>. They are useful when the derived relationship is real. A database update is not automatically Partial<DatabaseRow> if some fields must change together or must never be writable.

Literal inference can be preserved with as const:

ts
const currencies = ["USD", "EUR", "JPY"] as const;
type Currency = (typeof currencies)[number];

The runtime array and compile-time union now share a source. as const also makes properties deeply readonly in the inferred type, so use it intentionally rather than scattering it until errors disappear.

Structural types work best when they describe capabilities and data shapes. When two structurally identical primitives must not be mixed—customer IDs and invoice IDs, for example—a small branded type can add nominal friction:

ts
declare const customerIdBrand: unique symbol;
type CustomerId = string & { readonly [customerIdBrand]: true };

Brand creation should happen through a validating function. Brands are compile-time distinctions only; at runtime the value remains a string.

The goal is not to encode every fact into the type system. Use unions and narrowing to eliminate invalid program states that matter, while runtime validation continues to guard the outside world.

Further reference