Урок 19: Псевдонимы типов с union и intersection в TypeScript

Сегодня у нас очень интересная тема. Мы уже познакомились с основами объединений (union) и пересечений (intersection) типов. Но сегодня мы выведем эти концепции на совершенно новый уровень, научившись создавать мощные и переиспользуемые конструкции с помощью псевдонимов типов (type aliases).

До сих пор мы использовали union и intersection «на месте», прямо в аннотациях переменных или параметров функций. Это работает, но часто приводит к дублированию кода и сложностям в его чтении. Представьте, что один и тот же сложный union-тип вам нужно написать в десяти разных местах. Рано или поздно вы ошибиетесь, а вносить изменения будет крайне неудобно. Псевдонимы типов решают эту проблему, позволяя давать сложным типам понятные имена и использовать их по всему коду.

В этом уроке мы будем комбинировать все изученное ранее, базовые типы, интерфейсы, дженерики, union и intersection, чтобы создавать по-настоящему гибкие и надежные типовые конструкции.

Что такое псевдоним типа (type alias)?

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

typescript
// Объявляем псевдоним для примитивного типа (редкий случай, но возможный)
type UserName = string;
let name: UserName = 'Максим';

// Объявляем псевдоним для объектного типа (более полезно)
type User = {
  id: number;
  name: string;
  email: string;
};

const currentUser: User = {
  id: 1,
  name: 'Anna',
  email: 'anna@example.com'
};

// Объявляем псевдоним для union-типа (очень полезно!)
type ID = number | string;

function getUserById(id: ID): User | null {
  // ... логика поиска пользователя по id
  return null;
}

// Объявляем псевдоним для кортежа (tuple)
type Coordinates = [number, number];
const position: Coordinates = [55.7558, 37.6173];

Главное преимущество псевдонимов, это переиспользуемость и читаемость. Вместо того чтобы каждый раз писать number | string, мы пишем ID. Сразу понятно, для чего предназначена переменная, которая имеет этот тип. Кроме того, если мы захотим изменить тип ID на string (например, перейдя с числовых идентификаторов на UUID), нам нужно будет внести изменение всего в одном месте, в объявлении псевдонима type ID = string;. И это изменение автоматически применится ко всему коду, где используется ID.

Важно помнить, что псевдоним это всего лишь алиас, альтернативное имя. TypeScript не создает новый, отдельный тип. Он лишь подставляет на место псевдонима его определение. Это называется «номинативная» vs «структурная» система типов. В TypeScript она структурная: если две структуры совпадают, то типы считаются совместимыми, независимо от их имен.

Создание сложных union-типов с помощью псевдонимов

Union-типы это мощнейший инструмент для моделирования состояний, которые могут быть представлены в нескольких разных вариантах. Псевдонимы позволяют давать этим вариантам осмысленные имена, делая код самодокументируемым.

Один из самых классических примеров, это моделирование состояния асинхронной операции, например, загрузки данных.

typescript
// Без псевдонимов громоздко и нечитаемо
function handleState(state: { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: string } | { status: 'error'; error: Error }) {
  // ...
}

// С псевдонимами чисто и понятно
type StateIdle = { status: 'idle' };
type StateLoading = { status: 'loading' };
type StateSuccess = { data: string };
type StateError = { error: Error };

// Объединяем все состояния в один тип
type RequestState = StateIdle | StateLoading | StateSuccess | StateError;

// Функция становится намного читабельнее
function handleState(state: RequestState) {
  switch (state.status) {
    case 'idle':
      console.log('Начинаем загрузку...');
      break;
    case 'loading':
      console.log('Загружаем...');
      break;
    case 'success':
      console.log('Данные:', state.data); // TypeScript знает, что здесь есть data
      break;
    case 'error':
      console.log('Ошибка:', state.error.message); // TypeScript знает, что здесь есть error
      break;
  }
}

Обратите внимание, как TypeScript использует сужение типов (type narrowing) внутри блока switch или if. Для каждого case он точно знает, с каким именно членом union-а мы имеем дело и позволяет нам обращаться к специфическим для этого члена свойствам (data для 'success'error для 'error'). Это невероятно мощная возможность, которая исключает целый класс ошибок времени выполнения.

Другой отличный пример, это работа с DOM. Мы часто получаем элементы через document.querySelector, который может вернуть элемент разных типов.

typescript
// Псевдоним для элементов, с которыми мы планируем работать
type FocusableElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;

function setFocusToElement(element: FocusableElement) {
  element.focus(); // Метод .focus() есть у всех этих элементов
}

const input = document.querySelector('input[type="text"]');
if (input instanceof HTMLInputElement) { // Сужение типа
  setFocusToElement(input); // Теперь TypeScript уверен, что input - HTMLInputElement, который входит в FocusableElement
}

Создание intersection-типов с помощью псевдонимов

Если union-типы говорят «ИЛИ» (тип А или тип В), то intersection-типы говорят «И» (тип А и тип В одновременно). Они объединяют все свойства нескольких типов в один. Это идеальный инструмент для композиции и миксинов.

Самый частый use-case, это расширение существующих типов. Допустим, у нас есть базовый тип пользователя, но в разных частях приложения к нему нужно добавлять разную дополнительную информацию.

typescript
// Базовый тип пользователя
type BaseUser = {
  id: number;
  name: string;
};

// Дополнительная информация для профиля
type UserProfile = {
  avatarUrl: string;
  bio: string;
  birthDate: Date;
};

// Дополнительная информация для админ-панели
type AdminUserData = {
  permissions: string[];
  lastLogin: Date;
};

// Создаем расширенные типы через пересечение
type UserWithProfile = BaseUser & UserProfile;
type AdminUser = BaseUser & AdminUserData;

// Теперь переменная должна иметь ВСЕ свойства из BaseUser и ВСЕ из UserProfile
const user: UserWithProfile = {
  id: 1,
  name: 'Максим',
  avatarUrl: '/avatar.jpg',
  bio: 'Преподаватель TypeScript',
  birthDate: new Date(1990, 1, 1)
};

function promoteToAdmin(user: BaseUser): AdminUser {
  // Функция принимает BaseUser, а возвращает AdminUser (BaseUser + AdminUserData)
  return {
    ...user, // spread-оператор для копирования свойств BaseUser
    permissions: ['read', 'write'],
    lastLogin: new Date()
  };
}

Intersection отлично сочетается с интерфейсами и механизмом наследования. В отличие от extends в интерфейсах, которые создают иерархию, & просто склеивает свойства. Это более гибкий подход, особенно когда нужно смешать несколько типов.

Еще один паттерн, это комбинация union и intersection для создания дискриминируемых объединений с общими свойствами.

typescript
type NetworkLoadingState = {
  state: 'loading';
};

type NetworkFailedState = {
  state: 'failed';
  code: number;
};

type NetworkSuccessState = {
  state: 'success';
  response: {
    title: string;
    duration: number;
  };
};

// Создаем union type из всех состояний
type NetworkState = NetworkLoadingState | NetworkFailedState | NetworkSuccessState;

// А теперь создадим тип, который гарантированно имеет свойство 'state'
// и дополнительно что-то еще (пересечение с чем-то общим)
type NetworkStateWithTimestamp = NetworkState & {
  timestamp: number; // Добавляем новое свойство ко ВСЕМ членам union'а
};

// Пример использования
const state: NetworkStateWithTimestamp = {
  state: 'success',
  response: { title: 'Урок 19', duration: 3000 },
  timestamp: Date.now() // Это свойство теперь обязательное для любого состояния
};

Комбинирование подходов

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

typescript
// Базовые типы
type UUID = string; // Псевдоним для наглядности
type Department = 'engineering' | 'design' | 'marketing';

// Базовый интерфейс для всех сотрудников
interface BaseEmployee {
  id: UUID;
  name: string;
  department: Department;
  startDate: Date;
}

// Типы для разных ролей
interface DeveloperRole {
  role: 'developer';
  programmingLanguages: string[];
  level: 'junior' | 'middle' | 'senior';
}

interface DesignerRole {
  role: 'designer';
  designTools: string[];
}

interface ManagerRole {
  role: 'manager';
  teamSize: number;
}

// Объединяем роли в union type
type Role = DeveloperRole | DesignerRole | ManagerRole;

// Создаем полный тип сотрудника через пересечение BaseEmployee и Role
type Employee = BaseEmployee & Role;

// Теперь создадим тип для проекта
type Project = {
  id: UUID;
  name: string;
  deadline: Date;
  assignedEmployees: UUID[]; // массив id сотрудников
};

// А это тип для отчета, который объединяет данные
type ProjectReport = Project & {
  completed: boolean;
  team: Employee[]; // массив полных объектов сотрудников
};

// Функция для приветствия сотрудника использует сужение типов
function greetEmployee(employee: Employee) {
  console.log(`Hello, ${employee.name} from ${employee.department}!`);

  // В зависимости от роли, выводим разную информацию
  switch (employee.role) {
    case 'developer':
      console.log(`Keep coding in ${employee.programmingLanguages.join(', ')}!`);
      break;
    case 'designer':
      console.log(`Don't forget to use ${employee.designTools[0]} today!`);
      break;
    case 'manager':
      console.log(`Your team of ${employee.teamSize} people is waiting for you!`);
      break;
  }
}

// Пример создания сотрудника
const newEmployee: Employee = {
  id: 'a1b2-c3d4-e5f6',
  name: 'Алина',
  department: 'engineering',
  startDate: new Date(),
  role: 'developer', // без этого свойства объект не валиден
  programmingLanguages: ['TypeScript', 'JavaScript'],
  level: 'middle'
};

greetEmployee(newEmployee);

Этот пример демонстрирует, как мы можем создавать сложные, но при этом строго типизированные структуры данных. Мы четко моделируем предметную область: у каждого сотрудника есть общие свойства, но в зависимости от его роли (role), добавляются специфические. TypeScript будет следить, чтобы мы не забыли указать programmingLanguages для разработчика и не пытались добавить это свойство дизайнеру.

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

Давайте решим несколько задач.

Задача 1: Моделирование данных формы

Создайте тип для данных формы регистрации. Форма может быть в двух состояниях:

  1. Минимальная форма: только email и password.

  2. Расширенная форма: email, password, firstName, lastName, age.

Используйте дискриминируемый union по свойству formType: 'minimal' | 'extended'.

Решение:

typescript
type MinimalForm = {
  formType: 'minimal';
  email: string;
  password: string;
};

type ExtendedForm = {
  formType: 'extended';
  email: string;
  password: string;
  firstName: string;
  lastName: string;
  age: number;
};

type RegistrationForm = MinimalForm | ExtendedForm;

// Функция для отправки формы
function submitForm(form: RegistrationForm) {
  // Сначала обрабатываем общие поля
  console.log(`Email: ${form.email}, Password: ${form.password}`);

  // Затем сужаем тип для обработки специфичных полей
  if (form.formType === 'extended') {
    console.log(`Welcome, ${form.firstName} ${form.lastName}! You are ${form.age} years old.`);
  } else {
    console.log('Quick registration completed!');
  }
}

// Примеры использования:
const minimalData: RegistrationForm = {
  formType: 'minimal',
  email: 'quick@user.com',
  password: '123456'
};

const extendedData: RegistrationForm = {
  formType: 'extended',
  email: 'full@user.com',
  password: 'abcdef',
  firstName: 'Максим',
  lastName: 'Габов',
  age: 99
};

submitForm(minimalData);
submitForm(extendedData);

Задача 2: Функция-валидатор

Напишите функцию, которая принимает значение типа string | number | undefined и возвращает его длину. Для строки длину строки, для числа количество цифр в числе (приведите число к строке), для undefined, возвращайте 0. Используйте сужение типов.

Решение:

typescript
type InputValue = string | number | undefined;

function getLength(value: InputValue): number {
  if (value === undefined) {
    return 0;
  }

  if (typeof value === 'number') {
    return value.toString().length;
  }

  // TypeScript здесь понимает, что остались только строки
  return value.length;
}

console.log(getLength(undefined)); // 0
console.log(getLength(12345));     // 5
console.log(getLength('Hello'));   // 5

Задача 3: Композиция объектов

Создайте два базовых типа: WithId (свойство id: number) и WithTimestamp (свойства createdAt: DateupdatedAt?: Date). Создайте тип Post для статьи блога, который включает в себя свойства этих двух типов, а также собственные: title: string и content: string.

Решение:

typescript
type WithId = {
  id: number;
};

type WithTimestamp = {
  createdAt: Date;
  updatedAt?: Date; // необязательное свойство
};

type Post = WithId & WithTimestamp & {
  title: string;
  content: string;
};

// Создаем объект типа Post
const myPost: Post = {
  id: 1,
  title: 'Новая статья',
  content: 'Содержание статьи...',
  createdAt: new Date(),
  // updatedAt можно не указывать, так как оно optional
};

console.log(`Пост #${myPost.id} создан в ${myPost.createdAt.toLocaleTimeString()}`);

Задача 4: Расширение типа функции

Используйте intersection, чтобы добавить свойство description к функциональному типу () => void.

Решение:

typescript
type BasicFunction = () => void;

type FunctionWithDescription = BasicFunction & {
  description: string;
};

const myFunction: FunctionWithDescription = () => {
  console.log('Функция выполнена!');
};

myFunction.description = 'Это моя кастомная функция';

// Вызываем как обычную функцию
myFunction(); // 'Функция выполнена!'
// И имеем доступ к свойству
console.log(myFunction.description); // 'Это моя кастомная функция'

Этот паттерн иногда используется в JavaScript-библиотеках для добавления метаданных к функциям.

Заключение

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

Поработайте с практическими задачами, попробуйте применить эти подходы в своем коде. Например, перепишите типы в одном из своих учебных проектов, используя пройденные сегодня методы. Вы сразу увидите, насколько код станет чище и выразительнее.

В следующих уроках мы продолжим углубляться в систему типов и поговорим о более продвинутых концепциях. Обещаю, будет так же интересно и полезно!

Удачи в практике и до скорой встречи в двадцатом уроке!

Полный курс с уроками по TypeScript для начинающих

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

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

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