| |

TypeScript 6 🔷 Union and Intersection Types

A union type says “this value is one of several types.” An intersection type says “this value is all of several types at once.” They’re the two ways TypeScript combines existing types, and they’re opposites in almost every way. Unions widen — they allow more possibilities. Intersections narrow — they require more. Every conditional type, every discriminated union, every complex object shape builds on these two operators. Understanding them is understanding how TypeScript composes types.

Key point: The operators are | for union and & for intersection. But the symbols aren’t the point — the semantics are. A union is a set of alternatives; an intersection is a set of requirements. Read A | B as “A or B” and A & B as “A and B.” Every time you see one of these, that reading tells you what the type means.


Union types — A | B

A union type is a value that can be any one of several types.

let id: string | number;
id = 'abc';                       // ✅
id = 42;                          // ✅
id = true;                        // ❌ boolean not in the union

At each moment, id holds exactly one value — either a string or a number. The union describes the set of possibilities, not a hybrid type. A string | number isn’t a number that’s also a string; it’s a value that’s either.

Function parameters:

function format(value: string | number): string {
  return typeof value === 'number' ? value.toFixed(2) : value;
}

format('hello');                  // ✅
format(42);                       // ✅
format(true);                     // ❌

Inside the function, value is string | number. You can’t call .toFixed() directly — TypeScript doesn’t know if it’s a number. You have to narrow first.

Unions of many types:

type Result = string | number | boolean | null | undefined;

Unions of literals:

type Direction = 'north' | 'south' | 'east' | 'west';
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
type Flag = true | false;         // same as boolean

Literal unions are one of TypeScript’s most useful patterns — they let you describe a closed set of allowed values without an enum.

What you can do with a union:

function describe(value: string | number): string {
  // Only operations valid on BOTH types are allowed
  value.toString();               // ✅ both have toString
  value.length;                   // ❌ only strings have length
  value.toFixed(2);               // ❌ only numbers have toFixed
  return String(value);
}

You can only access members common to all branches of the union — unless you narrow first.

Unions collapse duplicates:

type A = string | string;         // string
type B = string | number | string; // string | number

| is idempotent and commutative. Order doesn’t matter; duplicates disappear.

Unions with never:

type C = string | never;          // string

never is the identity for union — adding it changes nothing. never is the bottom type — an empty set — and the union with anything is that anything.

Why unions matter: They model “one of these” — the most common shape in real code. A field that might be a string or a number, a result that might be success or failure, an option that might be set or unset. Almost every conditional in your code has a union type describing the branches. Learning to read them is learning to read types.


Narrowing — using a union safely

The point of a union is that you don’t know which branch you have. To use a specific branch, you narrow — provide information the compiler can use to eliminate alternatives.

typeof narrowing:

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

Inside the if, TypeScript knows value is a string — the typeof check eliminated the number branch. After the if, only number remains.

in narrowing — checking for a property:

interface Dog {
  bark: () => void;
}
interface Cat {
  meow: () => void;
}

function speak(pet: Dog | Cat): void {
  if ('bark' in pet) {
    pet.bark();                   // pet is Dog here
  } else {
    pet.meow();                   // pet is Cat here
  }
}

in checks whether a property exists. If bark is present, the value must be a Dog.

instanceof narrowing:

function handle(error: Error | string): string {
  if (error instanceof Error) {
    return error.message;         // error is Error
  }
  return error;                   // error is string
}

instanceof narrows to a class. Only works for class instances — not for interfaces or type aliases, which don’t exist at runtime.

Truthiness narrowing:

function greet(name: string | null | undefined): string {
  if (name) {
    return `Hello, ${name}`;      // name is string (non-empty)
  }
  return 'Hello, stranger';
}

Truthiness checks narrow out null, undefined, '', 0, false, NaN. Useful but broad — for strings it also eliminates the empty string.

Equality narrowing:

function move(dir: 'north' | 'south' | 'east' | 'west'): void {
  if (dir === 'north' || dir === 'south') {
    // dir is 'north' | 'south' here
  } else {
    // dir is 'east' | 'west' here
  }
}

=== narrows literal unions to the matching branches.

Exhaustiveness with switch:

function area(shape: 'circle' | 'square' | 'triangle'): number {
  switch (shape) {
    case 'circle': return 3.14;
    case 'square': return 4;
    case 'triangle': return 3;
  }
}

TypeScript knows the switch covers all cases. If you added a new shape and forgot a case, the function’s return type would fail — the compiler catches missing branches.

User-defined type guards — is:

function isString(value: unknown): value is string {
  return typeof value === 'string';
}

function handle(value: string | number): void {
  if (isString(value)) {
    value.toUpperCase();          // value is string
  } else {
    value.toFixed(2);             // value is number
  }
}

value is string is a type predicate. It tells TypeScript “if this function returns true, value is a string.” The compiler trusts the annotation — it’s your responsibility to make the check correct.

Narrowing is covered in depth in a later chapter. For now: unions need narrowing before use, and TypeScript narrows with typeof, in, instanceof, equality, and truthiness.

Why narrowing is essential: Without narrowing, union types would be nearly useless — you’d never be able to call methods specific to a branch. Narrowing is what makes unions ergonomic. The compiler tracks control flow through your ifs, switches, and guards, eliminating branches as it goes.


Intersection types — A & B

An intersection type is a value that satisfies all the listed types simultaneously.

interface Named {
  name: string;
}
interface Aged {
  age: number;
}

type Person = Named & Aged;

const alice: Person = {
  name: 'Alice',
  age: 30
};

A Person must have both name and age. It’s not “one or the other” — it’s both.

What A & B requires:

  • Every property of A must be present
  • Every property of B must be present
  • If a property exists in both, the types must be compatible

Merging object types:

type Employee = { name: string; id: number };
type Manager = { reports: Employee[] };
type ManagerEmployee = Employee & Manager;

const m: ManagerEmployee = {
  name: 'Alice',
  id: 1,
  reports: []
};

Intersections are like combining interfaces with extends, but inline.

Intersections with primitives — usually a mistake:

type Impossible = string & number;   // never

A value can’t be both a string and a number. TypeScript resolves string & number to never — the empty set. This is the correct behavior, but it often surprises people.

Intersections with unknown and any:

type A = string & unknown;            // string
type B = string & any;                // any

unknown is the identity for intersection — intersecting with it changes nothing. any overrides everything.

Property type conflicts:

interface A {
  value: string;
}
interface B {
  value: number;
}

type C = A & B;
// value: string & number → never
// C is effectively { value: never }

When the same property appears in both types with incompatible types, the intersection requires the property to satisfy both — usually impossible, so the property becomes never.

If the property types are compatible — say string and string — the result is string. If one is more specific — like 'a' | 'b' and 'b' | 'c' — the intersection narrows to 'b'.

Why intersections matter: They model composition. A value that’s simultaneously a Named, an Aged, and a Serializable. They’re the type-level equivalent of “and” — the way to add requirements. Combined with unions, they let you build any object shape by composing smaller pieces.


Unions vs intersections — the mental model

The two operators are duals. Every fact about one has a mirror in the other.

Union (|)Intersection (&)
Reading“A or B”“A and B”
Set theoryUnion of setsIntersection of sets
Value satisfiesAt least oneAll
Properties availableCommon to allAll of all
Identityneverunknown
Absorbing elementunknownnever
Use caseAlternativesComposition

Properties available is the key practical difference:

type U = { a: string } | { b: number };
type I = { a: string } & { b: number };

// On a U, you can only access properties common to both — none here.
// On an I, you can access both a and b.

declare const u: U;
u.a;                              // ❌ property 'a' doesn't exist on U
u.b;                              // ❌ property 'b' doesn't exist on U

declare const i: I;
i.a;                              // ✅ string
i.b;                              // ✅ number

A union of object types exposes only the common properties. An intersection exposes all properties. That’s the whole practical difference in one example.

Distributivity: Unions distribute over intersections.

type D = (A | B) & C;
// Equivalent to: (A & C) | (B & C)

Not directly true in TypeScript’s evaluation, but conceptually useful.

Optional properties in intersections:

type A = { x?: string };
type B = { x: string };

type C = A & B;
// x: string (required, because B requires it)

The intersection combines the requirements — if either side requires a property, the result requires it.

Why the dual nature matters: Once you see union and intersection as duals, a lot of TypeScript stops being arbitrary. never and unknown are the identities. Distributive conditionals apply to unions. Optional properties interact with intersections in specific ways. The pattern is consistent — unions are “or,” intersections are “and,” and their algebraic properties follow.


Union and intersection together

Real types combine both.

type Success = { status: 'success'; data: string };
type Failure = { status: 'error'; message: string };

type Result = Success | Failure;

function handle(r: Result): string {
  if (r.status === 'success') {
    return r.data;                // narrowed to Success
  }
  return r.message;               // narrowed to Failure
}

Result is a union of two object types. Each has a status property with a literal type. The status field is the discriminant — it tells you which branch you have.

This is the discriminated union pattern, and it’s one of the most powerful in TypeScript. It combines:

  • Union of object types
  • A common literal property (the discriminant)
  • Narrowing via === on the discriminant

Discriminated unions get a full chapter later. For now, notice that they build directly on union types.

Combining with intersections:

type Base = { id: number; createdAt: Date };

type Success = Base & { status: 'success'; data: string };
type Failure = Base & { status: 'error'; message: string };

type Result = Success | Failure;

Each branch is an intersection with a common Base. The union holds both branches. The whole shape is a discriminated union with shared properties.

Why this composition is idiomatic: The Base & { ... } pattern lets you factor shared properties out of the union. Instead of repeating id and createdAt on every branch, they live in Base. The union stays readable, and adding a shared field touches one type instead of five.


Practical patterns

A function returning multiple possible types:

function parse(input: string): number | null {
  const n = Number(input);
  return Number.isNaN(n) ? null : n;
}

const result = parse('42');
if (result !== null) {
  result.toFixed(2);              // narrowed to number
}

A “maybe” pattern:

type Maybe<T> = T | null | undefined;

function get<T>(map: Map<string, T>, key: string): Maybe<T> {
  return map.get(key);
}

Optional fields via union:

type UserInput = {
  name: string;
  email: string;
  age?: number;                   // age?: number is sugar for number | undefined
};

Composing type extensions:

type Timestamps = {
  createdAt: Date;
  updatedAt: Date;
};

type SoftDelete = {
  deletedAt: Date | null;
};

type Entity = Timestamps & SoftDelete & {
  id: string;
};

Intersections compose shared behavioral mixins.

Narrowing helper functions:

function isError(x: unknown): x is Error {
  return x instanceof Error;
}

function getMessage(err: unknown): string {
  if (isError(err)) return err.message;
  if (typeof err === 'string') return err;
  return 'Unknown error';
}

Each is predicate narrows unknown to a specific type.

Why these patterns are everywhere: They model the way real code handles ambiguity. Optional fields, nullable results, “this or that” variants — all of them are unions. Shared behavioral traits, feature mixins, entity bases — all intersections. Once you see the patterns, you see them in every codebase.


A full example

A small payment system with discriminated union results and shared base types.

// Shared properties
type Timestamps = {
  createdAt: Date;
};

// Payment methods — a discriminated union
type PaymentMethod =
  | { kind: 'card'; last4: string }
  | { kind: 'bank'; iban: string }
  | { kind: 'paypal'; email: string };

// Result types — a discriminated union with a shared base
type Base = Timestamps & {
  id: string;
  amount: number;
};

type PaymentSuccess = Base & {
  status: 'success';
  method: PaymentMethod;
};

type PaymentFailure = Base & {
  status: 'failure';
  reason: string;
  retryable: boolean;
};

type PaymentResult = PaymentSuccess | PaymentFailure;

// Narrowing on the discriminant
function summarize(result: PaymentResult): string {
  if (result.status === 'success') {
    switch (result.method.kind) {
      case 'card': return `Paid with card ending ${result.method.last4}`;
      case 'bank': return `Paid via bank ${result.method.iban}`;
      case 'paypal': return `Paid via PayPal ${result.method.email}`;
    }
  }
  return `Failed: ${result.reason}${result.retryable ? ' (retryable)' : ''}`;
}

// Usage
const success: PaymentResult = {
  id: 'p-1',
  amount: 42.5,
  createdAt: new Date(),
  status: 'success',
  method: { kind: 'card', last4: '4242' }
};

const failure: PaymentResult = {
  id: 'p-2',
  amount: 10,
  createdAt: new Date(),
  status: 'failure',
  reason: 'Insufficient funds',
  retryable: false
};

console.log(summarize(success));    // Paid with card ending 4242
console.log(summarize(failure));    // Failed: Insufficient funds

Every piece is a union or intersection:

  • PaymentMethod — union of three object types
  • Base — intersection with Timestamps
  • PaymentSuccess / PaymentFailure — intersections of Base and branch-specific fields
  • PaymentResult — union of the two branches

Narrowing happens on status first, then on method.kind — two levels of discriminated union.

Why this shape: It’s how real domain models work. Payments have methods (card, bank, PayPal), states (success, failure), and shared fields (id, amount, timestamp). Each dimension is a union. Each combination is an intersection. The result is a type that’s impossible to misconstruct without the compiler rejecting it.


Complete Example Session

# ============================================
# PART 1: UNION BASICS
# ============================================

cat > unions.ts << 'EOF'
type ID = string | number;

let id: ID = 'abc';
id = 42;
id = true;  // ❌ boolean not in union

function format(v: string | number): string {
  if (typeof v === 'string') return v.toUpperCase();
  return v.toFixed(2);
}

console.log(format('hi'), format(3.14159));
EOF

npx tsc --noEmit unions.ts
# [ unions.ts:5:1 - Type 'boolean' is not assignable to type 'ID'. ]

# ============================================
# PART 2: LITERAL UNIONS
# ============================================

cat > literals.ts << 'EOF'
type Direction = 'north' | 'south' | 'east' | 'west';
type Dice = 1 | 2 | 3 | 4 | 5 | 6;

function move(d: Direction): void {
  if (d === 'north' || d === 'south') {
    console.log('vertical', d);
  } else {
    console.log('horizontal', d);
  }
}

const roll: Dice = 4;
move('north');
console.log(roll);
EOF

npx tsc --noEmit literals.ts
# (no errors)

# ============================================
# PART 3: INTERSECTIONS
# ============================================

cat > intersections.ts << 'EOF'
interface Named { name: string; }
interface Aged { age: number; }

type Person = Named & Aged;

const alice: Person = { name: 'Alice', age: 30 };

// Union vs intersection properties
type U = { a: string } | { b: number };
type I = { a: string } & { b: number };

declare const u: U;
declare const i: I;

// u.a;  // ❌ not on both branches
// u.b;  // ❌
i.a;     // ✅
i.b;     // ✅

console.log(alice, i);
EOF

npx tsc --noEmit intersections.ts
# (no errors)

# ============================================
# PART 4: DISCRIMINATED UNIONS
# ============================================

cat > discriminated.ts << 'EOF'
type Success = { status: 'success'; data: string };
type Failure = { status: 'error'; message: string };

type Result = Success | Failure;

function handle(r: Result): string {
  if (r.status === 'success') {
    return r.data;
  }
  return r.message;
}

console.log(handle({ status: 'success', data: 'ok' }));
console.log(handle({ status: 'error', message: 'bad' }));
EOF

npx tsc --noEmit discriminated.ts
# (no errors)

# ============================================
# PART 5: FULL EXAMPLE
# ============================================

cat > payments.ts << 'EOF'
type PaymentMethod =
  | { kind: 'card'; last4: string }
  | { kind: 'paypal'; email: string };

type Success = { status: 'success'; method: PaymentMethod };
type Failure = { status: 'failure'; reason: string };

type Result = Success | Failure;

function summarize(r: Result): string {
  if (r.status === 'success') {
    return r.method.kind === 'card'
      ? `card ${r.method.last4}`
      : `paypal ${r.method.email}`;
  }
  return `failed: ${r.reason}`;
}

console.log(summarize({ status: 'success', method: { kind: 'card', last4: '4242' } }));
console.log(summarize({ status: 'failure', reason: 'insufficient funds' }));
EOF

npx tsc --noEmit payments.ts
# (no errors)

# ============================================
# PART 6: COMPILE AND RUN
# ============================================

npx tsc unions.ts literals.ts intersections.ts discriminated.ts payments.ts
node unions.js
# [ HI 3.14 ]

node literals.js
# [ vertical north ]
# [ 4 ]

node discriminated.js
# [ ok ]
# [ bad ]

node payments.js
# [ card 4242 ]
# [ failed: insufficient funds ]

Quick Reference

Operators

OperatorMeaningReading
|Union“or”
&Intersection“and”

Union Facts

FactValue
Value satisfiesAt least one branch
Properties availableCommon to all branches
Identitynever
DuplicatesCollapse
OrderDoesn’t matter
With neverUnchanged

Intersection Facts

FactValue
Value satisfiesAll branches
Properties availableAll from all branches
Identityunknown
DuplicatesMerge
Conflicting typesnever for that property
With nevernever

Narrowing Tools

ToolNarrows on
typeof x === 'string'Primitive type
x instanceof CClass instance
'prop' in xProperty presence
x === 'literal'Literal value
x === null / x == nullNull / undefined
x != nullNon-null
if (x)Truthy
x is T (predicate)Custom guard

Common Patterns

PatternType
Optional fieldT | undefined
Nullable fieldT | null
Nullable resultT | null | undefined
Enum-likeLiteral union
Multiple returnTuple or object
Discriminated unionUnion of objects with literal discriminant
MixinIntersection of traits
Base + variantBase & { ... }

Union vs Intersection — Object Example

A | BA & B
{ a: string } | { b: number }a not accessibleboth a and b
AssignmentNeeds A or BNeeds both
Common shapeIntersection of propertiesUnion of properties
Use caseEither/orComposition

Special Types

TypeUnion identityIntersection identity
never✅ identityabsorbing (result: never)
unknownabsorbing (result: unknown)✅ identity
anyabsorbingabsorbing

Best Practices

Do This:

// Use literal unions for closed sets
type Status = 'loading' | 'success' | 'error';          // ✅

// Narrow before using a union
if (typeof v === 'string') { v.toUpperCase(); }         // ✅

// Use `is` predicates for custom guards
function isUser(x: unknown): x is User { ... }          // ✅

// Use intersections for composition
type Entity = Base & Timestamps & SoftDelete;           // ✅

// Use discriminated unions for variants
type Result =
  | { status: 'ok'; data: string }
  | { status: 'error'; message: string };               // ✅

// Use exhaustive switches
switch (status) {
  case 'loading': ...
  case 'success': ...
  case 'error': ...
}                                                        // ✅

// Combine union with intersection for bases
type Success = Base & { status: 'ok'; data: string };   // ✅

Don’t Do This:

// Don't access union members without narrowing
function f(v: string | number) {
  v.toFixed(2);                                          // ❌ not on string
}

// Don't intersect incompatible primitives
type Bad = string & number;                              // ❌ never

// Don't intersect interfaces with conflicting props
interface A { x: string; }
interface B { x: number; }
type C = A & B;                                          // ❌ x: never

// Don't use union where intersection is meant
type User = { name: string } | { age: number };          // ❌ properties not both

// Don't rely on truthiness for nullable numbers
if (count) { }  // 0 is falsy                                   // ⚠️  use !== undefined

// Don't over-nest unions of unions
type X = (A | B) | (C | D);                              // ⚠️  flatten to A | B | C | D

// Don't assume `A & B` merges conflicting types
type M = { x: string } & { x: number };                  // ⚠️  x: never

Common Pitfalls

PitfallProblemSolution
Accessing union propsNot common to allNarrow first
Confusing | with &Opposite meaningRead as or/and
string & numberneverUse union instead
Conflicting intersection propsProperty becomes neverAlign types
Truthiness on numbers0 treated as missingExplicit !== undefined
Truthiness on strings'' treated as missingExplicit check
Forgetting exhaustivenessMissing case slips throughUse never check
Nested unionsHard to readFlatten
Union in genericComplex inferenceConsider helpers

Real-World Examples

1. ID that’s string or number

type ID = string | number;

2. Optional function parameter

function log(msg: string, level?: 'info' | 'warn' | 'error') { }

3. Return null on failure

function parse(s: string): number | null { }

4. Narrowing with typeof

function fmt(v: string | number) {
  return typeof v === 'number' ? v.toFixed(2) : v;
}

5. Narrowing with in

if ('bark' in pet) { pet.bark(); }

6. Narrowing with instanceof

if (err instanceof Error) { err.message; }

7. Custom type guard

function isString(x: unknown): x is string {
  return typeof x === 'string';
}

8. Discriminated union

type State =
  | { kind: 'idle' }
  | { kind: 'loading' }
  | { kind: 'done'; data: string[] };

9. Intersection for mixins

type Entity = Identifiable & Timestamps & SoftDelete;

10. Base + variant

type Success = Base & { status: 'ok'; data: string };
type Error = Base & { status: 'error'; message: string };

11. Combining properties

type Combined = { a: string } & { b: number };
// { a: string; b: number }

12. Optional via union

type User = { name: string; age?: number };
// age: number | undefined

13. Literal union for enum-like

type Size = 'sm' | 'md' | 'lg';

14. Exhaustive switch

switch (status) {
  case 'loading': return 0;
  case 'success': return 1;
  case 'error': return 2;
  default: {
    const _exhaustive: never = status;
    return _exhaustive;
  }
}

15. Union in array

const values: (string | number)[] = [1, 'a', 2];

16. Tuple with union

type Entry = [string, string | number];

17. Multiple return types

function firstOrNull<T>(xs: T[]): T | null {
  return xs.length ? xs[0] : null;
}

18. Interop with third-party unions

function handle(x: string | Error | undefined): string {
  if (x === undefined) return 'none';
  if (typeof x === 'string') return x;
  return x.message;
}

19. Widening and narrowing

let x: string | number = 'hello';
x = 42;              // ✅
x = true;            // ❌

20. Intersection of generics

function merge<A, B>(a: A, b: B): A & B {
  return { ...a, ...b } as A & B;
}

Visual: Union vs Intersection

┌──────────────────────────────────────────────┐
│  Union (|)                                   │
│                                              │
│  A ┌───┐                                     │
│    │   │                                     │
│    └───┘                                     │
│       B ┌───┐                                │
│         │   │                                │
│         └───┘                                │
│                                              │
│  A ∪ B — either side                         │
│  Properties: only shared                     │
│                                              │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│  Intersection (&)                            │
│                                              │
│  A ┌───────┐                                 │
│    │   ┌───┼───┐                             │
│    │ B │   │   │                             │
│    └───┼───┘   │                             │
│        └───────┘                             │
│                                              │
│  A ∩ B — overlap only                        │
│  Properties: all of both                     │
│                                              │
└──────────────────────────────────────────────┘

Visual: Property Access

┌──────────────────────────────────────────────┐
│  type U = { a: string } | { b: number };     │
│                                              │
│  On a U value:                               │
│    • a ❌ not on both branches                │
│    • b ❌ not on both branches                │
│    • need narrowing first                    │
│                                              │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│  type I = { a: string } & { b: number };     │
│                                              │
│  On an I value:                              │
│    • a ✅                                     │
│    • b ✅                                     │
│    • both available immediately              │
│                                              │
└──────────────────────────────────────────────┘

Visual: Narrowing Flow

┌──────────────────────────────────────────────┐
│  let v: string | number | boolean            │
│                                              │
│  if (typeof v === 'string') {                │
│    v is string    ─── narrowed               │
│  } else if (typeof v === 'number') {         │
│    v is number    ─── narrowed               │
│  } else {                                    │
│    v is boolean   ─── remaining              │
│  }                                           │
│                                              │
│  Each branch eliminates the other types      │
│                                              │
└──────────────────────────────────────────────┘

Visual: Discriminated Union

┌──────────────────────────────────────────────┐
│  type Shape =                                │
│    | { kind: 'circle'; radius: number }      │
│    | { kind: 'square'; side: number };       │
│                                              │
│  function area(s: Shape) {                   │
│    switch (s.kind) {                         │
│      case 'circle':                          │
│        return Math.PI * s.radius ** 2;       │
│        // s is circle here                   │
│                                              │
│      case 'square':                          │
│        return s.side ** 2;                   │
│        // s is square here                   │
│    }                                         │
│  }                                           │
│                                              │
│  The `kind` field is the discriminant        │
│                                              │
└──────────────────────────────────────────────┘

Visual: Base + Variant Pattern

┌──────────────────────────────────────────────┐
│  type Base = { id: string; createdAt: Date };│
│                                              │
│  type Success = Base & {                     │
│    status: 'success';                        │
│    data: string;                             │
│  };                                          │
│                                              │
│  type Failure = Base & {                     │
│    status: 'failure';                        │
│    message: string;                          │
│  };                                          │
│                                              │
│  type Result = Success | Failure;            │
│                                              │
│  Result has: id, createdAt, status + branch  │
│                                              │
└──────────────────────────────────────────────┘

Visual: Identity and Absorbing Elements

┌──────────────────────────────────────────────┐
│  Union identity: never                       │
│                                              │
│  A | never  =  A                             │
│  never | A  =  A                             │
│                                              │
│  (never has no values, so adds nothing)      │
│                                              │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│  Intersection identity: unknown              │
│                                              │
│  A & unknown  =  A                           │
│  unknown & A  =  A                           │
│                                              │
│  (unknown requires nothing, so adds nothing) │
│                                              │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│  Absorbing elements                          │
│                                              │
│  A | unknown  =  unknown                     │
│  A & never    =  never                       │
│                                              │
│  (each swallows the other operator)          │
│                                              │
└──────────────────────────────────────────────┘

Visual: Literal Union vs Enum

┌──────────────────────────────────────────────┐
│  Literal union                               │
│                                              │
│  type Status = 'loading' | 'ready' | 'error';│
│                                              │
│  • No runtime code                           │
│  • Erased at compile                         │
│  • Values are strings                        │
│  • Preferred in modern TS                    │
│                                              │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│  Enum                                        │
│                                              │
│  enum Status { Loading, Ready, Error }       │
│                                              │
│  • Emits runtime object                      │
│  • Has reverse mapping (numeric)             │
│  • Values are enum members                   │
│  • Legacy but still supported                │
│                                              │
└──────────────────────────────────────────────┘

Summary

ConceptMeaning
Union A | BValue is A or B
Intersection A & BValue is A and B
NarrowingRefining a union to a specific branch
Type guardFunction returning x is T
DiscriminantLiteral field that picks a union branch
Discriminated unionUnion of objects with a discriminant
Literal unionUnion of specific values
Identity for |never
Identity for &unknown
Absorbing for |unknown
Absorbing for &never

Key takeaways:

  • Union (|) means “one of” — the value is any one of the listed types
  • Intersection (&) means “all of” — the value satisfies every listed type
  • On a union, only common properties are accessible — narrow first
  • On an intersection, all properties are accessible immediately
  • Narrowing with typeof, in, instanceof, equality, and truthiness refines unions
  • Type predicates (x is T) create custom guards
  • Literal unions ('a' | 'b' | 'c') are the modern way to model closed sets — no runtime cost
  • Discriminated unions combine a common literal field with branching logic
  • Intersections compose types — Base & { extra } is the standard pattern
  • A & B with conflicting property types produces never for that property
  • never is the identity for union; unknown is the identity for intersection
  • A | unknown is unknown; A & never is never
  • Exhaustive switches with a never check catch missing branches

Remember: Union is “or,” intersection is “and.” Unions describe alternatives — you get one of them, and you must narrow before using branch-specific properties. Intersections describe composition — you get all of them, and every property is available. Every complex type in TypeScript is built from combinations of these two operators, so understanding them is understanding the language. Read A | B as “A or B,” read A & B as “A and B,” and everything else follows.


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!