Episode 1 — Fundamentals / 1.25 — TypeScript Essentials

1.25.f — Union and Intersection Types

In one sentence: Union types (A | B) let a value be one of several types, while intersection types (A & B) combine multiple types into one — together with type narrowing, they form the backbone of TypeScript's expressive type system.

Navigation: ← 1.25 Overview · 1.25.g — Generic Functions →


1. Union types — one OF several types

A union type says: "this value can be type A or type B (or C, etc.)":

// Basic union: string OR number
let id: string | number;
id = "abc-123";   // OK
id = 42;           // OK
// id = true;      // Error: Type 'boolean' is not assignable to type 'string | number'

// Union in function parameters
function printId(id: string | number): void {
  console.log(`ID: ${id}`);
}

printId("abc");  // OK
printId(123);    // OK

// Union with more types
type Result = string | number | boolean | null;

The problem: When you have a union, you can only use properties/methods common to all types in the union:

function printId(id: string | number): void {
  // id.toUpperCase();  // Error: 'toUpperCase' does not exist on type 'number'
  // id.toFixed(2);     // Error: 'toFixed' does not exist on type 'string'
  console.log(id.toString());  // OK — both string and number have toString()
}

To use type-specific methods, you must narrow the type first.


2. Type narrowing — determining the actual type

Narrowing is the process of refining a union type to a more specific type using checks that TypeScript understands:

typeof guard

function printId(id: string | number): void {
  if (typeof id === "string") {
    // TypeScript knows: id is string here
    console.log(id.toUpperCase());
  } else {
    // TypeScript knows: id is number here
    console.log(id.toFixed(2));
  }
}

instanceof guard

function logError(error: Error | string): void {
  if (error instanceof Error) {
    console.log(error.message);   // Error type
    console.log(error.stack);
  } else {
    console.log(error);           // string type
  }
}

in operator guard

interface Bird {
  fly(): void;
  layEggs(): void;
}

interface Fish {
  swim(): void;
  layEggs(): void;
}

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

Equality narrowing

function process(value: string | number | null): void {
  if (value === null) {
    console.log("No value");
    return;
  }
  // TypeScript knows: value is string | number here
  if (typeof value === "string") {
    console.log(value.toUpperCase());
  }
}

Custom type guard functions

interface Cat { meow(): void; name: string; }
interface Dog { bark(): void; name: string; }

// Type predicate: 'animal is Cat'
function isCat(animal: Cat | Dog): animal is Cat {
  return "meow" in animal;
}

function handleAnimal(animal: Cat | Dog): void {
  if (isCat(animal)) {
    animal.meow();   // TypeScript knows: Cat
  } else {
    animal.bark();   // TypeScript knows: Dog
  }
}

3. Discriminated unions — the most powerful pattern

A discriminated union uses a common literal property (the "discriminant") to distinguish between variants:

// Each variant has a 'type' property with a unique literal value
interface Circle {
  type: "circle";
  radius: number;
}

interface Square {
  type: "square";
  side: number;
}

interface Rectangle {
  type: "rectangle";
  width: number;
  height: number;
}

type Shape = Circle | Square | Rectangle;

function getArea(shape: Shape): number {
  switch (shape.type) {
    case "circle":
      // TypeScript knows: shape is Circle
      return Math.PI * shape.radius ** 2;
    case "square":
      // TypeScript knows: shape is Square
      return shape.side ** 2;
    case "rectangle":
      // TypeScript knows: shape is Rectangle
      return shape.width * shape.height;
  }
}

// Usage:
getArea({ type: "circle", radius: 5 });      // 78.54
getArea({ type: "square", side: 4 });         // 16
getArea({ type: "rectangle", width: 3, height: 7 }); // 21

Exhaustiveness checking

Add a default case with never to ensure you handle all variants:

function getArea(shape: Shape): number {
  switch (shape.type) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
    case "rectangle":
      return shape.width * shape.height;
    default:
      // If you add a new shape to the union but forget to handle it,
      // this line becomes a compile error:
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

// Now if you add: interface Triangle { type: "triangle"; ... }
// and update Shape = Circle | Square | Rectangle | Triangle
// but forget to add a case — TypeScript shows an error at 'default'

4. Intersection types — satisfying ALL types

An intersection type combines multiple types — the value must satisfy every type in the intersection:

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

// Must have ALL properties from all three types:
type Person = HasName & HasAge & HasEmail;

const person: Person = {
  name: "Alice",
  age: 30,
  email: "alice@example.com",
};
// Missing any property → error

Intersection with interfaces

interface Serializable {
  serialize(): string;
}

interface Loggable {
  log(): void;
}

type SerializableAndLoggable = Serializable & Loggable;

// Must implement both:
const obj: SerializableAndLoggable = {
  serialize() { return JSON.stringify(this); },
  log() { console.log("logged"); },
};

Conflicting properties in intersections

When intersected types have the same property with different types, the result is never (impossible):

type A = { value: string };
type B = { value: number };
type C = A & B;
// C.value is: string & number — which is 'never' (no value is both string AND number)

// This makes C impossible to create:
// const c: C = { value: ??? };  // No valid value exists

5. Unions vs intersections — mental model

Union A | BIntersection A & B
MeaningA or BA and B
Value isOne of the typesAll of the types combined
Properties availableOnly those common to allAll properties from all types
Use caseFlexibility — accept multiple formsComposition — combine features
Set theoryUnion (A ∪ B) of valuesIntersection (A ∩ B) of constraints
// Union: accepts EITHER shape
function processInput(input: string | number): void { /* ... */ }

// Intersection: requires BOTH shapes
type Admin = User & { permissions: string[] };

6. null handling with unions

TypeScript uses union types to represent nullable values:

// Explicitly nullable
function findUser(id: number): User | null {
  const user = database.get(id);
  return user ?? null;
}

const user = findUser(1);
// user is: User | null

// Must narrow before using:
if (user !== null) {
  console.log(user.name);  // OK — TypeScript knows it's User
}

// Or use optional chaining:
console.log(user?.name);      // string | undefined
console.log(user?.name ?? "Unknown");  // string

7. Strict null checks

The strictNullChecks compiler option (included in strict: true) prevents null and undefined from being assigned to non-nullable types:

// WITH strictNullChecks: true (recommended)
let name: string = "Alice";
// name = null;       // Error: Type 'null' is not assignable to type 'string'
// name = undefined;  // Error: Type 'undefined' is not assignable to type 'string'

let maybeName: string | null = null;    // OK — explicitly nullable
let optionalName: string | undefined;    // OK — explicitly optional

// WITHOUT strictNullChecks: false (not recommended)
let name: string = null;      // No error — but dangerous!
let age: number = undefined;   // No error — but dangerous!

Always enable strictNullChecks (or better, strict: true). It prevents the single most common category of runtime errors: null reference errors.


8. Optional vs union with undefined

These are subtly different:

// Optional property — may not exist at all
interface Config {
  timeout?: number;  // Property may be absent OR undefined
}

// Union with undefined — property MUST exist, but value can be undefined
interface Config {
  timeout: number | undefined;  // Property must be present, value can be undefined
}
// Practical difference:
const a: { timeout?: number } = {};           // OK — 'timeout' is absent
const b: { timeout: number | undefined } = {}; // Error — 'timeout' is required

const c: { timeout: number | undefined } = { timeout: undefined }; // OK

In function parameters:

// Optional parameter — can be omitted
function delay(ms?: number): void { /* ... */ }
delay();       // OK
delay(1000);   // OK

// Required parameter that accepts undefined
function delay(ms: number | undefined): void { /* ... */ }
delay(undefined);  // OK — but must pass something
delay(1000);       // OK
// delay();        // Error — argument expected

9. Type narrowing patterns — detailed examples

Pattern 1: Narrowing with if / else

type ApiResult = 
  | { success: true; data: User }
  | { success: false; error: string };

function handleResult(result: ApiResult): void {
  if (result.success) {
    // TypeScript knows: { success: true; data: User }
    console.log(result.data.name);
  } else {
    // TypeScript knows: { success: false; error: string }
    console.log(result.error);
  }
}

Pattern 2: Narrowing with switch

type Action = 
  | { type: "INCREMENT"; amount: number }
  | { type: "DECREMENT"; amount: number }
  | { type: "RESET" };

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case "INCREMENT":
      return state + action.amount;
    case "DECREMENT":
      return state - action.amount;
    case "RESET":
      return 0;
  }
}

Pattern 3: Truthiness narrowing

function printName(name: string | null | undefined): void {
  if (name) {
    // TypeScript knows: name is string (truthy check eliminates null/undefined/empty string)
    console.log(name.toUpperCase());
  } else {
    console.log("No name provided");
  }
}

Pattern 4: Array.isArray

function processInput(input: string | string[]): string[] {
  if (Array.isArray(input)) {
    return input;           // string[]
  } else {
    return [input];         // wrap single string in array
  }
}

10. Real-world examples

API responses with different shapes

type ApiResponse<T> = 
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: { code: number; message: string } };

function renderUserList(response: ApiResponse<User[]>): string {
  switch (response.status) {
    case "loading":
      return "<p>Loading...</p>";
    case "success":
      return response.data
        .map(user => `<li>${user.name}</li>`)
        .join("");
    case "error":
      return `<p>Error ${response.error.code}: ${response.error.message}</p>`;
  }
}

Form state management

type FormField = 
  | { type: "text"; value: string; maxLength?: number }
  | { type: "number"; value: number; min?: number; max?: number }
  | { type: "select"; value: string; options: string[] }
  | { type: "checkbox"; value: boolean };

function validateField(field: FormField): boolean {
  switch (field.type) {
    case "text":
      return field.maxLength ? field.value.length <= field.maxLength : true;
    case "number":
      return (field.min === undefined || field.value >= field.min)
          && (field.max === undefined || field.value <= field.max);
    case "select":
      return field.options.includes(field.value);
    case "checkbox":
      return true;  // Always valid
  }
}

Event handling

type AppEvent = 
  | { kind: "click"; x: number; y: number; target: string }
  | { kind: "keypress"; key: string; modifiers: string[] }
  | { kind: "scroll"; position: number; direction: "up" | "down" };

function logEvent(event: AppEvent): void {
  switch (event.kind) {
    case "click":
      console.log(`Clicked ${event.target} at (${event.x}, ${event.y})`);
      break;
    case "keypress":
      console.log(`Key: ${event.key}, Modifiers: ${event.modifiers.join("+")}`);
      break;
    case "scroll":
      console.log(`Scrolled ${event.direction} to ${event.position}`);
      break;
  }
}

Key takeaways

  1. Union types (A | B) let a value be one of several types — use for flexibility.
  2. Intersection types (A & B) combine types — use for composition.
  3. Type narrowing (using typeof, instanceof, in, discriminants) refines a union to a specific type.
  4. Discriminated unions (common literal property + switch) are TypeScript's most powerful pattern.
  5. Enable strictNullChecks to make null/undefined explicit via union types.
  6. Optional (?) means a property can be absent; | undefined means it must be present but can be undefined.

Explain-It Challenge

Explain without notes:

  1. What is the difference between string | number and string & number?
  2. What is a discriminated union, and why is it useful?
  3. Name three ways to narrow a union type in TypeScript.

Navigation: ← 1.25 Overview · 1.25.g — Generic Functions →