Union и Intersection

Union (|) — «одно ИЛИ другое», Intersection (&) — «одно И другое». Два основных способа комбинирования типов.

Зачем нужно

  • Union позволяет переменной принимать значения нескольких типов
  • Intersection объединяет свойства нескольких типов в один
  • Discriminated unions — мощный паттерн для моделирования предметной области
  • Type narrowing позволяет безопасно работать с union-типами

Где используется

  • Параметры функций, принимающие разные типы: string | number
  • Состояния: Loading | Success | Error
  • Миксины типов: User & HasTimestamps
  • API-ответы: ApiSuccess | ApiError

Предпосылки

Union Types (|)

Union — значение может быть одним из перечисленных типов:

// Простой union
let value: string | number;
value = "hello"; // OK
value = 42;      // OK
value = true;    // Ошибка! boolean не в union

// Union параметров
function format(input: string | number): string {
  // Нужно сузить тип перед использованием
  if (typeof input === "string") {
    return input.toUpperCase(); // OK — TS знает что string
  }
  return input.toFixed(2);     // OK — TS знает что number
}

// Union с null/undefined
function find(id: number): User | null {
  // ...
}

Работа с union — доступны только общие свойства

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

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

function getAnimal: Bird | Fish {
  // ...
}

const animal = getAnimal;
animal.layEggs; // OK — есть в обоих типах
animal.fly;     // Ошибка! fly нет в Fish
animal.swim;    // Ошибка! swim нет в Bird

// Нужно сузить тип
if ("fly" in animal) {
  animal.fly; // OK — TS знает что Bird
}

Type Narrowing (сужение типов)

type Input = string | number | boolean | null;

function process(input: Input): string {
  // typeof guard
  if (typeof input === "string") {
    return input.toUpperCase();
  }

  // typeof guard
  if (typeof input === "number") {
    return input.toString();
  }

  // Truthiness guard
  if (input) {
    return "true";
  }

  // Оставшийся тип: null | false → но false — truthy? нет
  // Здесь input: false | null
  return "falsy";
}

// instanceof
function formatDate(date: string | Date): string {
  if (date instanceof Date) {
    return date.toISOString();
  }
  return new Date(date).toISOString();
}

Discriminated Unions

Самый мощный паттерн — union с общим полем-дискриминатором:

// Дискриминатор — поле type с литеральным типом
interface LoadingState {
  type: "loading";
}

interface SuccessState {
  type: "success";
  data: string;
}

interface ErrorState {
  type: "error";
  message: string;
  code: number;
}

type State = LoadingState | SuccessState | ErrorState;

function render(state: State): string {
  switch (state.type) {
    case "loading":
      return "Loading...";
    case "success":
      return state.data.join(", "); // OK — TS знает что data доступен
    case "error":
      return `Error ${state.code}: ${state.message}`; // OK
  }
}

Exhaustive check с never

function render(state: State): string {
  switch (state.type) {
    case "loading":
      return "Loading...";
    case "success":
      return state.data.join(", ");
    case "error":
      return `Error: ${state.message}`;
    default:
      // Если добавить новый state и забыть case — ошибка компиляции
      const _exhaustive: never = state;
      throw new Error(`Unhandled state: ${_exhaustive}`);
  }
}

Реальный пример: Redux Actions

interface AddTodoAction {
  type: "ADD_TODO";
  payload: { text: string };
}

interface ToggleTodoAction {
  type: "TOGGLE_TODO";
  payload: { id: number };
}

interface RemoveTodoAction {
  type: "REMOVE_TODO";
  payload: { id: number };
}

type TodoAction = AddTodoAction | ToggleTodoAction | RemoveTodoAction;

function todoReducer(state: Todo, action: TodoAction): Todo {
  switch (action.type) {
    case "ADD_TODO":
      return [...state, { id: Date.now(), text: action.payload.text(), done: false }];
    case "TOGGLE_TODO":
      return state.map((t) =>
        t.id === action.payload.id ? { ...t, done: !t.done } : t
      );
    case "REMOVE_TODO":
      return state.filter((t) => t.id !== action.payload.id);
  }
}

Intersection Types (&)

Intersection объединяет все свойства типов:

interface HasId {
  id: number;
}

interface HasName {
  name: string;
}

interface HasEmail {
  email: string;
}

// Intersection — все свойства вместе
type User = HasId & HasName & HasEmail;
// { id: number; name: string; email: string }

const user: User = {
  id: 1,
  name: "Alice",
  email: "alice@example.com",
};

Миксины с Intersection

interface Timestamps {
  createdAt: Date;
  updatedAt: Date;
}

interface SoftDelete {
  deletedAt: Date | null;
  isDeleted: boolean;
}

interface BaseUser {
  id: number;
  name: string;
}

// Комбинируем
type User = BaseUser & Timestamps & SoftDelete;
// {
//   id: number;
//   name: string;
//   createdAt: Date;
//   updatedAt: Date;
//   deletedAt: Date | null;
//   isDeleted: boolean;
// }

Intersection с конфликтующими свойствами

interface A {
  x: number;
  y: string;
}

interface B {
  x: string;  // Конфликт! x: number & string = never
  z: boolean;
}

type AB = A & B;
// { x: never; y: string; z: boolean }
// x: number & string = never — невозможный тип

const ab: AB = {
  x: 42,   // Ошибка! Type 'number' is not assignable to type 'never'
  y: "hello",
  z: true,
};

Intersection функциональных типов

type Logger = (message: string) => void;
type ErrorHandler = (error: Error) => void;

// Intersection функций создаёт overload
type LoggerOrErrorHandler = Logger & ErrorHandler;

// Можно вызвать и так и так:
const handler: LoggerOrErrorHandler = (input: string | Error) => {
  console.log(input);
};

Union vs Intersection

// Union: ИЛИ — значение одного из типов
type StringOrNumber = string | number;
// Может быть string ИЛИ number

// Intersection: И — значение со ВСЕМИ свойствами
type Named = { name: string } & { age: number };
// Должен иметь И name, И age

// Для примитивов
type A = string | number; // string ИЛИ number
type B = string & number; // never (невозможно быть и строкой и числом)

// Для объектов
type C = { a: 1 } | { b: 2 }; // { a: 1 } ИЛИ { b: 2 }
type D = { a: 1 } & { b: 2 }; // { a: 1, b: 2 } — оба свойства

Продвинутые паттерны

Conditional rendering типы

type Props =
  | { variant: "text"; content: string }
  | { variant: "image"; src: string; alt: string }
  | { variant: "video"; src: string; autoplay?: boolean };

function render(props: Props) {
  switch (props.variant) {
    case "text":
      return props.content;
    case "image":
      return `<img src="${props.src}" alt="${props.alt}">`;
    case "video":
      return `<video src="${props.src}" ${props.autoplay ? "autoplay" : ""}>`;
  }
}

Брендированные типы через Intersection

// Nominal typing через intersection с уникальным свойством
type UserId = number & { readonly __brand: "UserId" };
type PostId = number & { readonly __brand: "PostId" };

function createUserId(id: number): UserId {
  return id as UserId;
}

function getUser(id: UserId): void {}
function getPost(id: PostId): void {}

const userId = createUserId(1);
const postId = 2 as PostId;

getUser(userId);  // OK
getUser(postId);  // Ошибка! PostId !== UserId
getUser(3);       // Ошибка! number !== UserId

Частые ошибки

  1. Не сужать union перед использованием — TypeScript не позволит вызвать метод неоднозначного типа
  2. Intersection примитивов = neverstring & number это never
  3. Забывать exhaustive check — при добавлении нового варианта в union
  4. Путать union объектовA | B не означает что доступны свойства обоих типов
// Ошибка: доступ к свойству без сужения
function process(input: { name: string } | { title: string }): string {
  return input.name;  // Ошибка! name не гарантирован
  // Правильно:
  if ("name" in input) {
    return input.name;
  }
  return input.title;
}
  1. Конфликтующие свойства в intersection — результат будет never для конфликтующего поля

Практика

  1. Создайте discriminated union для сетевого запроса: Idle | Loading | Success | Error
  2. Напишите функцию с exhaustive switch и never-проверкой
  3. Создайте миксины через intersection: Timestamps & SoftDelete & HasId
  4. Реализуйте брендированные типы для UserId и OrderId
  5. Напишите type guard для discriminated union

Связанные темы

Ресурсы