← Курс/Union и Intersection типы#169 из 257+30 XP

Union и Intersection типы

Union types: A | B

Union type означает «значение одного из типов»:

type StringOrNumber = string | number

function format(value: StringOrNumber): string {
  if (typeof value === 'string') {
    return value.toUpperCase()  // TS знает: здесь string
  }
  return value.toFixed(2)       // TS знает: здесь number
}

format('hello')  // 'HELLO'
format(3.14159)  // '3.14'

TypeScript автоматически **сужает тип** (type narrowing) внутри блоков if/else, switch.

Intersection types: A & B

Intersection type означает «значение всех типов одновременно» — объект должен иметь все поля:

interface Named {
  name: string
}

interface Aged {
  age: number
}

type Person = Named & Aged  // имеет И name, И age

const p: Person = { name: 'Алексей', age: 30 }  // OK
// const bad: Person = { name: 'Алексей' }        // Ошибка: нет age

// Полезно для миксинов:
type AdminUser = User & { permissions: string[] }

Discriminated Unions (размеченные объединения)

Самый мощный паттерн TypeScript — добавляем поле-«тег» (обычно type или kind) для различения вариантов:

type Circle    = { kind: 'circle';   radius: number }
type Rectangle = { kind: 'rect';    w: number; h: number }
type Triangle  = { kind: 'triangle'; base: number; height: number }

type Shape = Circle | Rectangle | Triangle

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2  // TS знает: Circle
    case 'rect':
      return shape.w * shape.h            // TS знает: Rectangle
    case 'triangle':
      return shape.base * shape.height / 2
  }
}

Exhaustive checking с never

TypeScript может проверить что все случаи обработаны:

function assertNever(value: never): never {
  throw new Error(`Необработанный случай: ${JSON.stringify(value)}`)
}

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':    return Math.PI * shape.radius ** 2
    case 'rect':      return shape.w * shape.h
    case 'triangle':  return shape.base * shape.height / 2
    default:
      return assertNever(shape)
      // Если добавить новый вид Shape и забыть обработать —
      // TypeScript выдаст ошибку компиляции на этой строке!
  }
}

Практические примеры Union

// API ответ — классический discriminated union
type ApiResponse<T> =
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error';   message: string }

function render<T>(response: ApiResponse<T>) {
  switch (response.status) {
    case 'loading': return 'Загрузка...'
    case 'success': return JSON.stringify(response.data)
    case 'error':   return `Ошибка: ${response.message}`
  }
}

// Redux actions — тоже discriminated union
type Action =
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'SET'; value: number }

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case 'INCREMENT': return state + 1
    case 'DECREMENT': return state - 1
    case 'SET':       return action.value  // TS знает: есть value
  }
}

Intersection для расширения типов

// Базовые интерфейсы:
interface Timestamps {
  createdAt: Date
  updatedAt: Date
}

interface SoftDelete {
  deletedAt: Date | null
}

// Модель с временными метками и мягким удалением:
type UserModel = User & Timestamps & SoftDelete

// Partial intersection для обновлений:
type UserUpdate = Partial<User> & { updatedAt: Date }

Примеры

Discriminated union для фигур: getArea и describe с switch по kind, exhaustive check

// В TypeScript:
// type Shape = {kind:'circle', radius:number} | {kind:'rect', w:number, h:number} | ...
// В JS работаем с теми же объектами — просто без аннотаций типов

function getArea(shape) {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2
    case 'rect':
      return shape.w * shape.h
    case 'triangle':
      return shape.base * shape.height / 2
    default:
      // Exhaustive check — в TS здесь было бы assertNever(shape)
      throw new Error(`Неизвестный вид фигуры: ${shape.kind}`)
  }
}

function getPerimeter(shape) {
  switch (shape.kind) {
    case 'circle':   return 2 * Math.PI * shape.radius
    case 'rect':     return 2 * (shape.w + shape.h)
    case 'triangle': return shape.a + shape.b + shape.c
    default:
      throw new Error(`Неизвестный вид фигуры: ${shape.kind}`)
  }
}

function describe(shape) {
  const area = getArea(shape).toFixed(2)
  switch (shape.kind) {
    case 'circle':
      return `Круг (r=${shape.radius}): площадь ${area}`
    case 'rect':
      return `Прямоугольник (${shape.w}×${shape.h}): площадь ${area}`
    case 'triangle':
      return `Треугольник (основ=${shape.base}, выс=${shape.height}): площадь ${area}`
    default:
      throw new Error(`Неизвестный вид фигуры: ${shape.kind}`)
  }
}

// Фабричные функции — удобнее чем писать объект вручную
const circle   = (radius)          => ({ kind: 'circle', radius })
const rect     = (w, h)            => ({ kind: 'rect', w, h })
const triangle = (base, height, a = base, b = base, c = base) =>
  ({ kind: 'triangle', base, height, a, b, c })

// --- Демонстрация ---

const shapes = [
  circle(5),
  rect(4, 6),
  triangle(8, 3),
  circle(1),
  rect(10, 2),
]

console.log('=== Описание фигур ===')
shapes.forEach(s => console.log(describe(s)))

console.log('\n=== Сортировка по площади ===')
const sorted = [...shapes].sort((a, b) => getArea(a) - getArea(b))
sorted.forEach(s => {
  console.log(`  ${s.kind}: ${getArea(s).toFixed(2)}`)
})

console.log('\n=== Статистика по типам ===')
const grouped = shapes.reduce((acc, s) => {
  acc[s.kind] = (acc[s.kind] || 0) + 1
  return acc
}, {})
Object.entries(grouped).forEach(([kind, count]) => {
  console.log(`  ${kind}: ${count} шт.`)
})

console.log('\n=== Exhaustive check ===')
try {
  getArea({ kind: 'hexagon', side: 5 })
} catch (e) {
  console.log(`Ошибка: ${e.message}`)
}