Урок 18: Пересечения (Intersection Types) & в TypeScript

В предыдущих уроках мы с вами разобрали, как сужать типы с помощью объединений (Union Types), когда переменная может быть одним типом из нескольких. Это инструмент для гибкости. Но что, если нам нужно совместить несколько типов одновременно, а не выбрать один? Для этой задачи в TypeScript существует не менее элегантный и мощный механизм, Пересечения типов (Intersection Types).

Сегодня мы детально изучим, что такое пересечения, для чего они нужны, как их использовать и в чем их ключевые отличия от объединений. Мы рассмотрим массу практических примеров и, как всегда, закрепим знания интересными задачами.

Что такое Intersection Types?

Пересечение типов это способ комбинирования нескольких типов в один. Результирующий тип будет обладать всеми свойствами каждого из исходных типов. Если проводить аналогию, то объединение (Union) это логическое «ИЛИ» (либо тип A, либо тип B), а пересечение (Intersection) это логическое «И» (и тип A и тип B одновременно).

Синтаксис пересечения крайне прост: типы перечисляются через амперсанд &.

typescript
type A = { a: number };
type B = { b: string };

type C = A & B; // Тип C имеет и свойство `a` и свойство `b`

// Переменная типа C обязана иметь оба свойства.
const validObject: C = {
    a: 42,
    b: "hello"
};

const invalidObject: C = {
    a: 42
}; // Ошибка! Property 'b' is missing.

Главная идея пересечений это создание нового типа путем «склеивания» существующих. Это отличный способ компоновки объектов, интерфейсов и даже примитивов (хотя с последними это редко имеет практический смысл) в более сложные и специфичные структуры.

Зачем это нужно? Представьте, что вы работаете с различными сущностями в вашем приложении: пользователями, сообщениями, продуктами. У каждой из них есть свой уникальный набор полей. Но иногда возникает потребность в объекте, который должен соответствовать сразу нескольким контрактам. Например, объект, который одновременно является и администратором (имеет права администратора) и пользователем (имеет основные данные пользователя). Intersection Types идеально подходят для описания таких сущностей, делая ваш код типобезопасным, читаемым и легко компонуемым.

Комбинирование нескольких типов в один

Самое частое применение пересечений, комбинирование объектов. Давайте разберем это на классическом и простом для понимания примере.

Допустим, у нас в системе есть два независимых типа данных. Первый описывает пользователя с базовой информацией.

typescript
interface User {
    name: string;
    email: string;
    login(): void;
}

Второй тип описывает дополнительные права доступа, которые могут быть не у всех пользователей.

typescript
interface AdminPermissions {
    canDeleteUsers: boolean;
    canBanUsers: boolean;
    grantPermissions(): void;
}

Теперь, мы хотим описать тип «Администратор». Этот тип должен обладать всеми свойствами и обычного пользователя User и правами администратора AdminPermissions. Здесь на помощь приходит пересечение.

typescript
type Admin = User & AdminPermissions;

Что же представляет из себя тип Admin? TypeScript видит его следующим образом:

typescript
// Примерное представление (TypeScript внутренне так не создает, но логика именно такая)
interface Admin {
    // Все из User
    name: string;
    email: string;
    login(): void;

    // Все из AdminPermissions
    canDeleteUsers: boolean;
    canBanUsers: boolean;
    grantPermissions(): void;
}

Теперь мы можем создать объект, соответствующий этому типу.

typescript
const superAdmin: Admin = {
    name: "Максим",
    email: "m@gabov.ru",
    canDeleteUsers: true,
    canBanUsers: true,
    login() {
        console.log(`${this.name} вошел в систему.`);
    },
    grantPermissions() {
        console.log("Права выданы!");
    }
};

// Мы имеем доступ ко всем свойствам сразу.
superAdmin.login(); // "Максим вошел в систему."
console.log(superAdmin.canBanUsers); // true
superAdmin.grantPermissions(); // "Права выданы!"

Это и есть мощь пересечений. Мы не дублировали код, переопределяя интерфейс Admin с нуля, а заново использовали уже существующие типы, создав на их основе новый. Это соответствует принципам DRY (Don’t Repeat Yourself) и делает код легко поддерживаемым. Если мы позже добавим новое свойство в User, оно автоматически появится и в типе Admin.

Пересечение более двух типов

Пересекать можно не только два, но и любое количество типов. Механика остается абсолютно той же.

typescript
interface Loggable {
    log: () => void;
}

interface Serializable {
    serialize: () => string;
}

interface Identifiable {
    id: number | string;
}

// Создаем "универсальный" объект, который должен уметь всё.
type UniversalEntity = Identifiable & Loggable & Serializable;

const entity: UniversalEntity = {
    id: "abc-123",
    log() {
        console.log(`Entity ID: ${this.id}`);
    },
    serialize() {
        return JSON.stringify(this);
    }
};

А что насчет примитивных типов, таких как stringnumberboolean? Технически, TypeScript позволяет создавать их пересечения. Но давайте подумаем, что может означать тип string & number? Это значение, которое должно быть одновременно и строкой и числом. Поскольку такого значения в JavaScript не существует (за исключением символов, но это отдельная история), результатом такого пересечения будет тип never.

typescript
type Impossible = string & number; // Тип: never
const x: Impossible = "hello"; // Ошибка: Тип "string" не может быть назначен типу "never".
const y: Impossible = 42; // Та же ошибка.

Это полезно в advanced-сценариях с дженериками и условными типами, где можно проверить, является ли тип never, но на начальном уровне чаще всего вы будете сталкиваться с пересечением объектных типов.

Разрешение конфликтов при Intersection

Что произойдет, если мы попытаемся пересечь два типа, у которых есть свойства с одинаковыми именами, но разными типами? TypeScript попытается сделать пересечение и этих свойств.

typescript
type A = { prop: number; common: string };
type B = { prop: string; common: string };

type C = A & B;

Давайте проанализируем тип C. Что будет с свойством common? Оно одноименное, но оба исходных типа определяют его как string. Проблем нет. Тип свойства common в C останется string.

А что со свойством prop? В типе A это number, а в типе B — string. TypeScript попытается найти пересечение number & string. Как мы уже знаем, это never. Таким образом, тип C будет выглядеть так:

typescript
// Примерное представление типа C
{
    prop: never; // Свойство, которое нельзя никак присвоить
    common: string;
}

Попытка создать объект такого типа приведет к ошибке, потому что мы не можем присвоить значение типу never.

typescript
const obj: C = {
    common: "ok",
    prop: 123 // Ошибка! Тип 'number' не может быть назначен типу 'never'.
};

Такой код не имеет практического смысла и почти всегда является ошибкой проектирования типов. TypeScript помогает нам выявить эту коллизию на этапе компиляции.

Однако, если типы свойств совместимы (например, один тип более конкретная версия другого), конфликта не возникнет. Классический пример пересечение двух объектов, где у одного свойство необязательное, а у другого обязательное.

typescript
type Part = { optionalProp?: string };
type RequiredPart = { optionalProp: string };

type Combined = Part & RequiredPart;

// В результате `optionalProp` в `Combined` становится обязательным свойством типа `string`.
// Это логично: Part говорит "может быть string", RequiredPart говорит "должен быть string".
// Их пересечение: "должен быть string".
const test: Combined = { optionalProp: "now it's required" };

Пересечения и псевдонимы типов или Интерфейсы

Как вы уже видели в примерах, пересечения отлично работают как с interface, так и с type. В этом их универсальность. Однако есть нюанс, связанный с возможностями интерфейсов.

Интерфейсы поддерживают объединение через наследование (extends). Тот же тип Admin можно было бы создать так:

typescript
interface Admin extends User, AdminPermissions {}

Вопрос: что же использовать, пересечения типов (&) или наследование интерфейсов (extends)?

  • Наследование интерфейсов (extends):

    • Более традиционный, ООП-ориентированный подход.

    • Чуть лучше обрабатывается в ошибках: если есть конфликт, ошибка укажет на проблемное место в цепочке наследования.

    • Интерфейсы можно дополнять (объявление слиянием), что иногда полезно.

    • Более читаемо для описания иерархии «является» (Admin является User).

  • Пересечения псевдонимов типов (&):

    • Более функциональный, композитный подход.

    • Крайне гибкие. Могут пересекать не только интерфейсы, но и любые другие типы: объектные литералы, union-типы, типы, созданные утилитами (PartialPick и т.д.).

    • Не могут быть дополнены после объявления.

Часто выбор, дело вкуса и соглашений в команде. Лично я часто предпочитаю type с & за их универсальность, особенно когда нужно быстро скомбинировать что-то сложное. Но для описания четкой иерархии классов и объектов интерфейсы с наследованием тоже прекрасный выбор. Важно понимать оба подхода.

Практическое применение: Миксины (Mixins)

Одно из самых мощных применений Intersection Types, реализация паттерна Миксины (Mixins). Миксины это способ добавления поведения классам без использования классического наследования. По сути, это функции, которые принимают класс, добавляют ему новые методы и свойства и возвращают новый класс (расширенную версию).

TypeScript с помощью пересечений позволяет beautifully описать типы для таких операций.

Рассмотрим пример. У нас есть простой класс Car.

typescript
class Car {
    constructor(public brand: string) {}
    drive() {
        return "Driving...";
    }
}

Мы хотим добавить ему функциональность, например, возможность включать сигнализацию. Мы можем написать функцию-миксин, которая добавляет эту функциональность любому классу.

typescript
// Базовый тип для конструктора. Он описывает любой класс, конструктор которого возвращает экземпляр типа T.
type Constructor<T = {}> = new (...args: any[]) => T;

// Миксин, который добавляет функциональность сигнализации
function WithAlarm<TBase extends Constructor>(Base: TBase) {
    return class extends Base {
        isAlarmOn: boolean = false;

        triggerAlarm() {
            this.isAlarmOn = !this.isAlarmOn;
            console.log(this.isAlarmOn ? "Alarm is ON!" : "Alarm is OFF!");
        }
    };
}

Теперь применим этот миксин к нашему классу Car. Что будет типом результата? Это будет пересечение типа Car и типа, возвращаемого миксином WithAlarm.

typescript
// Создаем новый класс
const CarWithAlarm = WithAlarm(Car);

// Создаем экземпляр нового класса
const myCar = new CarWithAlarm("Toyota");

// У экземпляра есть ВСЕ методы и свойства от Car...
console.log(myCar.brand); // "Toyota"
console.log(myCar.drive()); // "Driving..."

// ... И от миксина WithAlarm!
myCar.triggerAlarm(); // "Alarm is ON!"
console.log(myCar.isAlarmOn); // true

TypeScript автоматически вывел тип для myCar как Car & { isAlarmOn: boolean; triggerAlarm: () => void; }. Это и есть основа пересечений в действии! Мы динамически создали новый тип, комбинируя статический класс и функциональность, привнесенную миксином.

Практические задачи для закрепления

Давайте закрепим знания на нескольких задачах. Попробуйте решить их самостоятельно, прежде чем смотреть решение.

Задача 1: Создание типа для ответа API
Представьте, что вы получаете с бэкенда данные о товаре. Есть базовый интерфейс Product и интерфейс ProductStock, который приходит из другого микросервиса. Создайте тип ProductWithStock, который включает всю информацию сразу.

typescript
interface Product {
    id: number;
    name: string;
    price: number;
}

interface ProductStock {
    stockCount: number;
    warehouse: string;
}

// Ваш код здесь: объявите тип ProductWithStock

// Проверка:
const product: ProductWithStock = {
    id: 1,
    name: "Ноутбук",
    price: 100000,
    stockCount: 5,
    warehouse: "Основной склад"
};
console.log(product); // Должно вывести полный объект без ошибок типов.

Задача 2: Композиция функций
Напишите функцию compose, которая принимает две функции с определенными сигнатурами и возвращает их композицию. Используйте дженерики и пересечения, чтобы корректно описать тип возвращаемого значения.

typescript
function firstFunction<T>(x: T): { value: T } {
    return { value: x };
}

function secondFunction<U>(x: { value: U }): { result: U } {
    return { result: x.value };
}

// Ваша задача - корректно типизировать функцию compose
function compose<A, B, C>(func1: (a: A) => B, func2: (b: B) => C): (a: A) => C {
    return (x: A) => func2(func1(x));
}

// Проверка:
const composed = compose(firstFunction, secondFunction);
const finalResult = composed("test");
console.log(finalResult); // { result: "test" }
// Тип finalResult должен быть { result: string }, а не any.

Задача 3: Исправление ошибки (Конфликт типов)
Дан следующий код. В нем есть преднамеренная ошибка. Объясните, почему TypeScript выдает ошибку и предложите два способа ее исправления: 1) путем изменения типов Person и Employee, 2) путем использования другого механизма вместо пересечения.

typescript
type Person = {
    id: number;
    name: string;
};

type Employee = {
    id: string; // Конфликт: number vs string
    department: string;
};

type CompanyMember = Person & Employee; // Здесь будет проблема

// Объяснение ошибки и ваши варианты решения:
// 1. ...
// 2. ...

Ответы и решения:

Задача 1:

typescript
type ProductWithStock = Product & ProductStock;

Задача 2:
Функция compose уже типизирована в условии задачи. Ключевой момент здесь, это понимание, как сквозной тип A проходит через B к C. Пересечения в явном виде здесь не нужны, но это основа для понимания передачи типов.

Задача 3:

  • Объяснение: При пересечении типов Person и Employee возникает конфликт по свойству id. TypeScript пытается создать тип number & string, который является never. Следовательно, свойство id в типе CompanyMember становится never и присвоить ему какое-либо значение становится невозможно.

  • Способ 1 (Изменение типов): Привести оба id к одному типу, например, string (поскольку number можно привести к string) или использовать union-тип number | string с самого начала.

    typescript
    type Person = { id: string; name: string; };
    type Employee = { id: string; department: string; };
  • Способ 2 (Отказ от пересечения): Если id в разных системах действительно имеют разный тип и мы не можем их изменить, то пересечение неверный подход. Нужно использовать объединение (|) или создать全新的 тип с двумя отдельными свойствами (например, personId: number и employeeId: string).

    typescript
    // Вариант A: Union (но это изменит семантику)
    type CompanyMember = Person | Employee;
    
    // Вариант B: Новый тип
    type CompanyMember = {
        personId: number;
        name: string;
        employeeId: string;
        department: string;
    };

Заключение

Мы научились совмещать разные типы в один, создавая богатые и точные описания для наших данных. Мы разобрались на примерах, как это работает с объектами, где происходит разрешение конфликтов и как это применяется в продвинутых паттернах like миксины.

Главное понимать разницу:

  • | (Union): переменная может быть одним из перечисленных типов.

  • & (Intersection): переменная должна быть всеми перечисленными типами одновременно.

Как всегда, если остались вопросы, не стесняйтесь пересматривать и задавать вопросы в комментариях.

Полный курс с уроками по TypeScript для начинающих вы можете найти на моем сайте: https://max-gabov.ru/typescript-dlya-nachinaushih

Поделиться статьей:
Поддержать автора блога

Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.

Персональные рекомендации
Оставить комментарий