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 | B | Intersection A & B | |
|---|---|---|
| Meaning | A or B | A and B |
| Value is | One of the types | All of the types combined |
| Properties available | Only those common to all | All properties from all types |
| Use case | Flexibility — accept multiple forms | Composition — combine features |
| Set theory | Union (A ∪ B) of values | Intersection (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
- Union types (
A | B) let a value be one of several types — use for flexibility. - Intersection types (
A & B) combine types — use for composition. - Type narrowing (using
typeof,instanceof,in, discriminants) refines a union to a specific type. - Discriminated unions (common literal property +
switch) are TypeScript's most powerful pattern. - Enable
strictNullChecksto makenull/undefinedexplicit via union types. - Optional (
?) means a property can be absent;| undefinedmeans it must be present but can beundefined.
Explain-It Challenge
Explain without notes:
- What is the difference between
string | numberandstring & number? - What is a discriminated union, and why is it useful?
- Name three ways to narrow a union type in TypeScript.
Navigation: ← 1.25 Overview · 1.25.g — Generic Functions →