Union и Intersection
Union (|) — «одно ИЛИ другое», Intersection (&) — «одно И другое». Два основных способа комбинирования типов.
Зачем нужно
- Union позволяет переменной принимать значения нескольких типов
- Intersection объединяет свойства нескольких типов в один
- Discriminated unions — мощный паттерн для моделирования предметной области
- Type narrowing позволяет безопасно работать с union-типами
Где используется
- Параметры функций, принимающие разные типы:
string | number - Состояния:
Loading | Success | Error - Миксины типов:
User & HasTimestamps - API-ответы:
ApiSuccess | ApiError
Предпосылки
- Примитивные типы — базовые типы
- Литеральные типы — литералы в union
- Type guards — сужение union-типов
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
Частые ошибки
- Не сужать union перед использованием — TypeScript не позволит вызвать метод неоднозначного типа
- Intersection примитивов = never —
string & numberэтоnever - Забывать exhaustive check — при добавлении нового варианта в union
- Путать 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;
}
- Конфликтующие свойства в intersection — результат будет
neverдля конфликтующего поля
Практика
- Создайте discriminated union для сетевого запроса: Idle | Loading | Success | Error
- Напишите функцию с exhaustive switch и
never-проверкой - Создайте миксины через intersection:
Timestamps & SoftDelete & HasId - Реализуйте брендированные типы для
UserIdиOrderId - Напишите type guard для discriminated union
Связанные темы
- Type guards — сужение union типов
- Литеральные типы — литералы в union
- Conditional types — условные типы с union
- Utility types — Extract, Exclude для работы с union