Unions & narrowing.
Model "one of these" safely.
Real data isn't always one type. An API response is either a success with data or an error with a message. A user's age is either a number or not yet set. A form field is either filled or empty. Union types model this — and narrowing makes them usable. This lesson covers the union operator, all six narrowing techniques, the discriminated union pattern that powers every real state machine, custom type guards, and exhaustive checking that catches missing cases at compile time. This is the lesson that changes how you design types.
A union (A | B) says "this value is A or B." Narrowing refines a union to a specific member based on runtime checks: typeof, instanceof, in, truthiness, equality, or a custom type guard. The discriminated union — a union where each member has a shared literal status/kind field — is the pattern that models state machines, API responses, and app state cleanly. Exhaustive checking with never catches missing cases at compile time. This trio — union + narrowing + exhaustiveness — is why TypeScript can model real data safely.
Before this lesson: complete Lesson 3 — generics and type parameters appear throughout.
🔀 Union types — the "or" of TypeScript
The pipe operator. One of the most-used types in real code.
A union type says a value can be one of several types. The syntax is the pipe |. string | number means "a string or a number." Any value assigned to that type must be one of them — not both, not something else.
// ─── Basic unions ──────────────────────────── type ID = string | number; const a: ID = 'u_123'; // ✅ string is allowed const b: ID = 456; // ✅ number is allowed // const c: ID = true; // ❌ boolean is not in the union // ─── Literal unions — a fixed set of values ── type Status = 'idle' | 'loading' | 'success' | 'error'; let s: Status = 'loading'; // ✅ // s = 'done'; // ❌ 'done' not in the union // s = 'Loading'; // ❌ case matters — 'Loading' ≠ 'loading' // ─── Union with null/undefined ─────────────── type MaybeUser = User | null | undefined; interface User { name: string } function getName(u: MaybeUser): string { // return u.name; // ❌ u might be null/undefined if (!u) return '(no user)'; // ✅ narrowed return u.name; } // ─── Union of object shapes ────────────────── type Shape = | { kind: 'circle'; radius: number } | { kind: 'square'; side: number }; function area(s: Shape): number { switch (s.kind) { case 'circle': return Math.PI * s.radius ** 2; case 'square': return s.side ** 2; } } console.log(getName({ name: 'Alice' })); console.log(getName(null)); console.log(area({ kind: 'circle', radius: 2 }));
// All type syntax erased. Runtime is plain JavaScript. const a = 'u_123'; const b = 456; let s = 'loading'; function getName(u) { if (!u) return '(no user)'; return u.name; } function area(s) { switch (s.kind) { case 'circle': return Math.PI * s.radius ** 2; case 'square': return s.side ** 2; } } console.log(getName({ name: 'Alice' })); console.log(getName(null)); console.log(area({ kind: 'circle', radius: 2 }));
Alice
(no user)
12.566370614359172
| means "or"
string | number = string OR number. A value satisfies it if it's one of them. This is the opposite of & (and), which requires both.
Literal unions
'idle' | 'loading' | 'success' | 'error' restricts to exactly those strings. Typos fail to compile. This replaces enums in modern code.
Union with null
User | null means "a user or nothing." TypeScript forces you to handle null before accessing properties — no more "Cannot read property of null."
Union of shapes
{ kind: 'circle', radius } | { kind: 'square', side } — the members differ, and the shared kind field is what lets you distinguish them. This is the discriminated union.
Unions are the type-level "or" — use them aggressively. The most common TypeScript mistake is under-modeling — using string when you mean 'idle' | 'loading' | 'success' | 'error'. The union is more precise, it catches typos at compile time, and it enables exhaustive checking. If a value can only be one of five things, its type should be a five-member union. Precision is the whole point.
🔍 typeof and instanceof — the primitive narrowers
Runtime checks that TypeScript turns into compile-time knowledge.
A union by itself is hard to use — TypeScript won't let you call string methods on string | number, because it might be a number. Narrowing refines the type inside a block. typeof handles primitives; instanceof handles class instances.
// ─── typeof narrows primitives ─────────────── function format(value: string | number | boolean): string { if (typeof value === 'string') { return value.toUpperCase(); // ✅ string methods available } if (typeof value === 'number') { return value.toFixed(2); // ✅ number methods available } // value is boolean here — the only remaining case return value ? 'yes' : 'no'; } // ─── typeof for null (gotcha) ───────────────── function describe(x: string | null): string { if (x === null) return 'nothing'; // ✅ must check null explicitly if (typeof x === 'string') return "${x}"`; return ''; } // ─── instanceof narrows class instances ────── class Cat { meow() { return 'meow'; } } class Dog { bark() { return 'woof'; } } function speak(pet: Cat | Dog): string { if (pet instanceof Cat) { return pet.meow(); // ✅ narrowed to Cat } return pet.bark(); // ✅ narrowed to Dog } // ─── Array.isArray ─────────────────────────── function join(x: string | string[]): string { if (Array.isArray(x)) { return x.join(', '); // ✅ narrowed to string[] } return x; // ✅ narrowed to string } console.log(format('hi'), format(3.14), format(true)); console.log(speak(new Cat()), speak(new Dog())); console.log(join(['a', 'b']));
function format(value) { if (typeof value === 'string') { return value.toUpperCase(); } if (typeof value === 'number') { return value.toFixed(2); } return value ? 'yes' : 'no'; } function describe(x) { if (x === null) return 'nothing'; if (typeof x === 'string') return "${x}"`; return ''; } class Cat { meow() { return 'meow'; } } class Dog { bark() { return 'woof'; } } function speak(pet) { if (pet instanceof Cat) { return pet.meow(); } return pet.bark(); } function join(x) { if (Array.isArray(x)) { return x.join(', '); } return x; } console.log(format('hi'), format(3.14), format(true)); console.log(speak(new Cat()), speak(new Dog())); console.log(join(['a', 'b']));
HI 3.14 no
meow woof
a, b
typeof returns a string
typeof x returns 'string', 'number', 'boolean', 'object', 'function', 'undefined', 'symbol', or 'bigint'. TypeScript uses the check to narrow.
typeof null === 'object'
A JavaScript historical quirk — typeof null is 'object'. To narrow null specifically, use x === null — not typeof.
instanceof for classes
pet instanceof Cat narrows to Cat. Works with custom classes and built-ins (Date, Map, Error). Doesn't work with interfaces — they have no runtime value.
Array.isArray
typeof [] === 'object' — so typeof can't distinguish arrays. Use Array.isArray, which TypeScript understands as a narrowing check for T[].
typeof cannot distinguish two object shapes. typeof { a: 1 } === 'object' and typeof { b: 2 } === 'object' — the same result. If your union is two interfaces, typeof won't narrow it. You need the discriminated union pattern (next section) or the in operator. This is the #1 reason beginners get stuck with union narrowing.
🔑 in and truthiness — narrowing object shapes
When typeof can't help, check the shape.
The in operator checks if a property exists on an object. TypeScript uses it to discriminate between union members that have different property sets. Truthiness narrowing handles null and undefined in one line.
// ─── The in operator narrows by shape ────── interface Bird { fly(): void; layEggs(): void } interface Fish { swim(): void; layEggs(): void } function move(animal: Bird | Fish): string { if ('fly' in animal) { animal.fly(); // ✅ narrowed to Bird return 'flew'; } animal.swim(); // ✅ narrowed to Fish return 'swam'; } // ─── Truthiness narrows null/undefined ──────── function greet(name?: string): string { if (!name) { return 'Hello, anonymous'; } // name is string here — null and undefined filtered out return Hello, ${name}`; } // ─── Careful: truthiness also filters "" and 0 ─ function badGreet(name?: string): string { if (!name) return 'anonymous'; // ⚠️ Empty string '' also returns 'anonymous' — probably not intended return Hello,${name}`; } // ─── Equality with literals narrows too ─────── type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; function isSafe(m: HttpMethod): boolean { if (m === 'GET') return true; // narrowed to 'GET' if (m === 'POST' || m === 'PUT') return false; // m is 'DELETE' here — the only remaining case return false; } // ─── Early-return pattern (very common) ─────── function process(user: User | null): string { if (!user) return '(guest)'; // early return narrows for the rest return user.name.toUpperCase(); } console.log(move({ fly() {}, layEggs() {} })); console.log(greet('Alice'), greet()); console.log(isSafe('GET'), isSafe('DELETE'));
function move(animal) { if ('fly' in animal) { animal.fly(); return 'flew'; } animal.swim(); return 'swam'; } function greet(name) { if (!name) { return 'Hello, anonymous'; } return Hello, ${name}`; } function badGreet(name) { if (!name) return 'anonymous'; return Hello,${name}`; } function isSafe(m) { if (m === 'GET') return true; if (m === 'POST' || m === 'PUT') return false; return false; } function process(user) { if (!user) return '(guest)'; return user.name.toUpperCase(); } console.log(move({ fly() {}, layEggs() {} })); console.log(greet('Alice'), greet()); console.log(isSafe('GET'), isSafe('DELETE'));
flew
Hello, Alice Hello, anonymous
true false
in checks property presence
'fly' in animal is a runtime check that narrows to the union member with a fly property. Works when union members have distinct property names.
Truthiness filters null/undefined
if (name) narrows string | undefined to string. Fast and idiomatic — but be careful, it also filters '', 0, and false.
Equality with literals
m === 'GET' narrows a literal union to that one value. This is what makes switch on a discriminant work — each case refines the type.
Early returns
An early if (!x) return narrows x for the rest of the function. This is the most common narrowing idiom in real code — flatter than nested ifs.
The early-return pattern is the professional default. Instead of nesting checks (if (user) { if (user.name) { ... } }), guard and return early: if (!user) return; if (!user.name) return;. The rest of the function then works with fully-narrowed types — no nesting, no ! assertions, no noise. Every TypeScript style guide recommends it. Once you adopt it, your functions get shorter and your narrowing gets automatic.
🎯 Discriminated unions — the pattern that scales
A shared literal field. Total narrowing. Zero ambiguity.
This is the single most important pattern in TypeScript. A discriminated union is a union where each member has a shared property — the discriminant — with a unique literal value. Checking that field narrows to the exact member, unlocking its specific fields. Every state machine, API response, Redux action, and form state in real code uses this pattern.
// ─── The discriminated union ────────────────── interface User { id: string; name: string } type Result = | { status: 'loading' } | { status: 'success'; data: User } | { status: 'error'; message: string }; function render(r: Result): string { switch (r.status) { case 'loading': return 'Loading...'; case 'success': return Hello, ${r.data.name}Error: ${r.message}; // ✅ r.message available } } // ─── The compiler enforces it ───────────────── function check(r: Result): string { if (r.status === 'success') { return r.data.name; // ✅ narrowed to success // return r.message; // ❌ 'message' doesn't exist on success } if (r.status === 'error') { return r.message; // ✅ narrowed to error } return 'loading'; // ✅ narrowed to loading } // ─── The pattern models state machines ─────── type FormState = | { status: 'idle' } | { status: 'submitting'; startedAt: Date } | { status: 'success'; id: string } | { status: 'error'; error: Error }; function describeForm(f: FormState): string { switch (f.status) { case 'idle': return 'Ready'; case 'submitting': return Submitting since ${f.startedAt.toISOString()}Saved as ${f.id}; case 'error': return Failed: ${f.error.message}`; } } console.log(render({ status: 'success', data: { id: '1', name: 'Alice' } })); console.log(render({ status: 'error', message: 'Not found' })); console.log(render({ status: 'loading' }));
function render(r) { switch (r.status) { case 'loading': return 'Loading...'; case 'success': return Hello,${r.data.name}Error: ${r.message}; } } function check(r) { if (r.status === 'success') { return r.data.name; } if (r.status === 'error') { return r.message; } return 'loading'; } function describeForm(f) { switch (f.status) { case 'idle': return 'Ready'; case 'submitting': return Submitting since${f.startedAt.toISOString()}Saved as {f.error.message}`; } } console.log(render({ status: 'success', data: { id: '1', name: 'Alice' } })); console.log(render({ status: 'error', message: 'Not found' })); console.log(render({ status: 'loading' }));
Hello, Alice
Error: Not found
Loading...
The discriminant field
Every member has status (or kind, type) with a unique literal value. That's the shared field the compiler uses to narrow. The name can be anything — status is just convention.
Per-member fields
data only exists on success. message only exists on error. Accessing the wrong field is a compile error — the type system prevents mixing state up.
Impossible states
{ status: 'loading', data: {...} } is impossible — loading has no data field. The union makes impossible states unrepresentable. This is the whole value of the pattern.
Switch = automatic narrowing
switch (r.status) with a case per literal narrows r inside each case. No manual checks, no assertions — the compiler tracks which branch you're in.
Model your app state as a discriminated union from day one. The most common React bug is a state that should be mutually exclusive but isn't: { loading: true, data: user, error: null } allows impossible combinations. A discriminated union makes it impossible: { status: 'loading' } | { status: 'success', data: User } | { status: 'error', error: Error }. If you adopt one pattern from this lesson, make it this one — it will eliminate entire classes of bugs in your codebase.
🛡️ Custom type guards — narrow what typeof can't
A function that returns x is Type. Your own narrowing operator.
When you can't narrow with typeof, instanceof, or in — usually because the type comes from an API and you're validating it at runtime — you write a type guard. It's a function that returns value is T, telling TypeScript "if this returns true, value is T."
// ─── A type guard: value is User ───────────── interface User { id: string; name: string } function isUser(x: unknown): x is User { return ( typeof x === 'object' && x !== null && 'id' in x && 'name' in x && typeof (x as User).id === 'string' && typeof (x as User).name === 'string' ); } // ─── Used in an if, it narrows ─────────────── function greet(x: unknown): string { if (isUser(x)) { return Hello, ${x.name}`; // ✅ x is User here } return 'Hello, stranger'; } // ─── Generic guard for arrays ──────────────── function isArrayOf( arr: unknown, guard: (item: unknown) => item is T ): arr is T[] { return Array.isArray(arr) && arr.every(guard); } const data: unknown = [ { id: '1', name: 'Alice' }, { id: '2', name: 'Bob' } ]; if (isArrayOf(data, isUser)) { // data is User[] here data.forEach(u => console.log(u.name)); } // ─── Validating API responses ──────────────── async function fetchUser(id: string): Promise { const res = await fetch(/api/users/${id}`); const json: unknown = await res.json(); // unknown, not any if (!isUser(json)) { throw new Error('Invalid user response'); } return json; // ✅ validated and narrowed } console.log(greet({ id: '1', name: 'Alice' })); console.log(greet('not a user')); console.log(greet(null));
function isUser(x) { return ( typeof x === 'object' && x !== null && 'id' in x && 'name' in x && typeof x.id === 'string' && typeof x.name === 'string' ); } function greet(x) { if (isUser(x)) { return Hello, ${x.name}`; } return 'Hello, stranger'; } function isArrayOf(arr, guard) { return Array.isArray(arr) && arr.every(guard); } const data = [ { id: '1', name: 'Alice' }, { id: '2', name: 'Bob' } ]; if (isArrayOf(data, isUser)) { data.forEach(u => console.log(u.name)); } async function fetchUser(id) { const res = await fetch(/api/users/${id}`); const json = await res.json(); if (!isUser(json)) { throw new Error('Invalid user response'); } return json; } console.log(greet({ id: '1', name: 'Alice' })); console.log(greet('not a user')); console.log(greet(null));
Hello, Alice
Hello, stranger
Hello, stranger
x is T predicate
The return type x is User is a type predicate. It tells TypeScript "if this function returns true, x is User." The function body can return any boolean.
Parameter is unknown
Guards should take unknown, not any — unknown forces you to check before accessing. Guards validate unknown into a specific type.
Validate before accessing
Inside the guard, TypeScript doesn't know x's shape yet, so you use (x as User) or property checks. That's the one place as is legitimate.
Generic guards compose
isArrayOf(arr, isUser) combines a generic container guard with a specific item guard. This is how you validate arrays from APIs without writing one guard per array type.
The API boundary pattern
API responses are unknown (not any). Guard them before use. This is the standard practice — you're not trusting the server's types, you're verifying the shape.
A type guard that lies is worse than no guard. function isUser(x: unknown): x is User { return true; } compiles — but every call narrows incorrectly. A guard returning true when the value isn't a User causes silent runtime bugs, exactly the bugs types are supposed to prevent. Every guard body must actually validate the shape. Test them: pass bad data, confirm they return false.
✅ Exhaustive checking — catch missing cases at compile time
When you add a new union member, the compiler finds every place that didn't handle it.
Exhaustive checking ensures every member of a union is handled. Inside a switch or if-else chain that covers all cases, the default branch gets the never type — meaning "this shouldn't happen." If you add a new union member later and forget a case, the compiler errors. This is how real codebases stay safe as state machines grow.
// ─── The pattern ───────────────────────────── type Shape = | { kind: 'circle'; radius: number } | { kind: 'square'; side: number } | { kind: 'triangle'; base: number; height: number }; function area(s: Shape): number { switch (s.kind) { case 'circle': return Math.PI * s.radius ** 2; case 'square': return s.side ** 2; case 'triangle': return (s.base * s.height) / 2; default: // s is never here — every case was handled const _exhaustive: never = s; return _exhaustive; } } // ─── If you add a new case... ───────────────── type Shape2 = | { kind: 'circle'; radius: number } | { kind: 'square'; side: number } | { kind: 'triangle'; base: number; height: number } | { kind: 'rectangle'; w: number; h: number }; // new! function area2(s: Shape2): number { switch (s.kind) { case 'circle': return Math.PI * s.radius ** 2; case 'square': return s.side ** 2; case 'triangle': return (s.base * s.height) / 2; // case 'rectangle': return s.w * s.h; // ← forgot this one! default: // ❌ Type 'Shape2' is not assignable to type 'never'. // Type '{ kind: "rectangle"; ... }' is not assignable const _exhaustive: never = s; return _exhaustive; } } // ─── A reusable helper ──────────────────────── function assertNever(x: never): never { throw new Error(Unexpected value: ${x}`); } console.log(area({ kind: 'circle', radius: 1 })); console.log(area({ kind: 'square', side: 3 }));
function area(s) { switch (s.kind) { case 'circle': return Math.PI * s.radius ** 2; case 'square': return s.side ** 2; case 'triangle': return (s.base * s.height) / 2; default: const _exhaustive = s; return _exhaustive; } } function area2(s) { switch (s.kind) { case 'circle': return Math.PI * s.radius ** 2; case 'square': return s.side ** 2; case 'triangle': return (s.base * s.height) / 2; default: const _exhaustive = s; return _exhaustive; } } function assertNever(x) { throw new Error(Unexpected value:${x}`); } console.log(area({ kind: 'circle', radius: 1 })); console.log(area({ kind: 'square', side: 3 }));
3.141592653589793
9
never means "no value"
When all union members are handled, the remaining type is never — the empty set. Assigning s to never proves the branch is unreachable.
Adding a case breaks the build
Add a new union member, forget a case, and the default branch fails to compile. That's the point — the compiler forces you to handle it.
assertNever helper
A reusable function that throws at runtime if reached. Useful when a switch should be exhaustive but you want a runtime safety net — production code throws, TypeScript users see the compile error first.
The most valuable pattern in TS
Exhaustive checking is what makes discriminated unions safe to evolve. Add a state, fix the compile errors, ship. Without it, you'd need to grep for every switch. This is why unions scale.
Combine discriminated unions with exhaustive checking — the pair is unbeatable. The discriminated union makes impossible states unrepresentable. The exhaustive check makes forgotten cases impossible to ship. Together they model every state machine in your app: form states, request states, order states, authentication states. Every Redux reducer, every React state, every API handler in real code should be structured as a discriminated union with a switch that exhausts all cases. This is the pattern that separates teams that ship confidently from teams that ship and pray.
🏆 Challenge
Three tasks. Each exercises a different narrowing pattern.
Task 1 — Model an API response
Design a discriminated union for an API fetch that reflects every real state:
1. idle — no request started yet (no extra fields)
2. loading — request in flight, has startedAt: Date
3. success — has data: T (generic over the data type)
4. error — has error: Error and retryable: boolean
Write a render function that takes this union and returns a string describing the state. Use a switch with exhaustive checking — add a default that assigns to never. Then add a fifth state (refreshing) and verify the compiler catches the missing case in every function that handles the union.
Task 2 — Write a type guard for API validation
Build a guard for an API response:
1. interface User { id: string; name: string; email: string }
2. function isUser(x: unknown): x is User — checks all three fields are strings
3. function isUserArray(x: unknown): x is User[] — reuses isUser via Array.isArray + every
4. A function processApiData(x: unknown): string that takes unknown, narrows with the guard, returns a formatted string, and returns 'invalid' if it fails
Test with: a valid user, an object missing email, a string, null, an array of valid users, and an array with one bad user. Each should hit the right branch — this is the pattern every real app needs at its API boundary.
Task 3 — Reducer with exhaustive checking
Build a mini Redux-style reducer:
1. State: { count: number; history: number[] }
2. Actions: { type: 'increment' } | { type: 'decrement' } | { type: 'add', by: number } | { type: 'reset' } | { type: 'undo' }
3. function reducer(state: State, action: Action): State
4. Every branch returns a new state — no mutation
5. The default branch uses assertNever or const _: never = action
Then dispatch a sequence of actions, log the final state. Then add a new action ({ type: 'multiply', by: number }) and verify the compiler finds the missing case. This is exactly how production Redux reducers are structured — if you can write this, you can write any state machine.
⚠️ Common mistakes
The narrowing bugs that catch every beginner.
typeof on object shapestypeof {a:1} === typeof {b:2} — both are 'object'. Fix: use a discriminated union with a kind field, or the in operator. This is the #1 stuck point for narrowing.
typeof null === 'object'. If you check typeof x === 'object' without guarding null, you'll crash. Fix: if (x !== null && typeof x === 'object').
if (name) filters null, undefined, and ''. If empty string is valid, you need name !== null && name !== undefined. Fix: be explicit when empty is meaningful.
Using { loading: boolean; data?: T; error?: Error } allows impossible states (loading: true, data: user). Fix: discriminated union — each state is its own shape with a status field.
function isUser(x): x is User { return true; } compiles and narrows incorrectly. Fix: guard bodies must actually validate the shape. Test each guard with bad data.
never in switch defaultWithout const _: never = x in the default, adding a union member doesn't break existing switches. Fix: always include the exhaustiveness check — it's the whole point.
unknown as anyWriting (x as User).name to silence the compiler is the same mistake as any. Fix: narrow properly with a guard, then use. Don't cast unknown — validate it.
anyfetch(...).then(r => r.json() as User) skips validation — the type is a lie. Fix: as unknown, then a type guard. Only narrowed values are safe to use.
❓ Frequently asked questions
What is a union type in TypeScript?
A union type says a value can be one of several types. type ID = string | number means ID is either a string or a number. The pipe | is the union operator. Unions are how TypeScript models 'this OR that' — API responses that can succeed or fail, values that can be present or null, states that can be one of a fixed set.
What is type narrowing?
Narrowing is how TypeScript refines a broad union type into a specific one based on runtime checks. If a value is string | number, checking typeof value === 'string' inside an if narrows it to string inside the block. TypeScript understands typeof, instanceof, in, Array.isArray, truthiness, equality with literals, discriminated unions, and custom type guards.
What is typeof narrowing?
The typeof operator returns a string ('string', 'number', 'boolean', 'object', 'function', 'undefined', 'symbol', 'bigint'). TypeScript uses this at compile time to narrow unions. if (typeof value === 'string') narrows value to string inside the block. It's the most common narrowing technique for primitive unions.
What is a discriminated union?
A discriminated union is a union where each member has a shared literal property (the discriminant) that uniquely identifies it. type Result = { status: 'ok'; data: User } | { status: 'error'; message: string }. The 'status' field is the discriminant — checking result.status === 'ok' narrows the type to the ok member, giving access to result.data.
What is a custom type guard?
A type guard is a function that returns a type predicate — value is SomeType. function isUser(x: unknown): x is User { return typeof x === 'object' && x !== null && 'name' in x; }. When you call isUser(value) in an if, TypeScript narrows value to User inside the block. Custom guards are how you narrow types that typeof and instanceof can't.
What does the in operator narrow?
The in operator checks if a property exists on an object. TypeScript uses it to discriminate between union members that have different property sets. if ('name' in value) narrows value to whichever union members have a 'name' property. This is useful when the union members are distinguished by a property's presence rather than a literal value.
What is exhaustive checking?
Exhaustive checking ensures every member of a union is handled. Inside a switch or if-else chain that covers all cases, the default branch gets the never type — and you can assign value to never to prove it's unreachable. If you add a new union member later, the compiler catches the missing case.
What is the difference between a union and an intersection?
A union (A | B) means 'A or B' — a value satisfies the type if it's either. An intersection (A & B) means 'A and B' — a value must have all properties of both. Use union when the value could be one of several shapes. Use intersection when combining shapes. They're opposites: union widens the set of allowed values, intersection narrows it.