9 мин. чтения

Продвинутые Utility Types в TypeScript.

Разбираем утилитные типы TypeScript простым языком: Partial, Pick, Omit, Record и другие. Что это, зачем нужно и как использовать - с понятными примерами.

Зачем вообще нужны эти "утилитные типы"?

Если вы только начинаете изучать TypeScript, вы наверняка столкнулись с ситуацией: у вас есть тип (описание структуры данных), и вам нужен почти такой же, но чуть-чуть другой. Например, у вас есть тип "Пользователь" с 10 полями, а для формы редактирования нужны только 3 из них.

Можно, конечно, написать новый тип руками. Но если у вас 50 типов и каждый нужен в 3-4 вариациях - вы утонете в дублирующемся коде. Вот тут на помощь приходят утилитные типы - специальные "трансформаторы", которые берут существующий тип и делают из него новый.

Давайте разберём их все на простых примерах. Обещаю - к концу статьи вы будете использовать их как профи (ну, или хотя бы перестанете пугаться, увидев Partial<Omit<User, 'id'>> в чужом коде).

Наш пример для всей статьи

Чтобы не придумывать новые примеры каждый раз, возьмём один тип и будем работать с ним:

// Описание пользователя на нашем сайте
// interface - это как "чертёж", описание структуры данных
interface User {
  id: number;          // Уникальный номер пользователя (1, 2, 3...)
  name: string;        // Имя ("Иван")
  email: string;       // Электронная почта ("ivan@example.com")
  avatar: string;      // Ссылка на аватарку ("/photos/ivan.jpg")
  role: 'admin' | 'user';  // Роль: или админ, или обычный пользователь
  createdAt: Date;     // Дата регистрации
}

Partial - делает все поля необязательными

Представьте: пользователь хочет изменить только своё имя. Ему не нужно отправлять email, аватарку и всё остальное - только имя. Но наш тип User требует ВСЕ поля. Что делать?

Partial берёт тип и делает все поля в нём необязательными (добавляет знак ? к каждому полю):

// Partial делает все поля необязательными
// Было: { id: number, name: string, email: string, ... }
// Стало: { id?: number, name?: string, email?: string, ... }
// Знак ? означает "это поле можно не указывать"

type UpdateUserData = Partial<User>;

// Теперь можно передать только те поля, которые меняются
async function updateUser(userId: number, data: Partial<User>) {
  // data может содержать любую комбинацию полей User
  // или не содержать ничего (пустой объект)
  await db.users.update(userId, data);
}

// Примеры использования:
updateUser(1, { name: 'Новое имя' });           // Меняем только имя
updateUser(1, { email: 'new@mail.com' });         // Только email
updateUser(1, { name: 'Иван', avatar: '/new.jpg' }); // Имя и аватар
// Всё это валидно! Partial разрешает любую комбинацию

Required - делает все поля обязательными

Противоположность Partial. Если у вас тип, где некоторые поля необязательные, Required сделает их все обязательными:

// Настройки приложения - некоторые поля можно не указывать
interface AppSettings {
  theme?: string;      // ? значит необязательно
  language?: string;   
  fontSize?: number;   
  notifications?: boolean;
}

// Required убирает все знаки ? - теперь ВСЕ поля обязательны
type FullSettings = Required<AppSettings>;
// { theme: string, language: string, fontSize: number, notifications: boolean }

// Полезно для функции, которая гарантирует заполнение всех настроек
function getFullSettings(partial: AppSettings): FullSettings {
  // Если пользователь не указал настройку - ставим значение по умолчанию
  return {
    theme: partial.theme ?? 'dark',           // ?? означает "если не указано, то..."
    language: partial.language ?? 'ru',
    fontSize: partial.fontSize ?? 14,
    notifications: partial.notifications ?? true
  };
}

Pick - выбираем только нужные поля

Допустим, для карточки пользователя на странице нужны только имя и аватар. Зачем тащить весь тип User с 6 полями? Pick позволяет "выбрать" только нужные поля:

// Pick<ОткудаБерём, КакиеПоляНужны>

// Для карточки пользователя нужны только имя и аватар
type UserCard = Pick<User, 'name' | 'avatar'>;
// Результат: { name: string, avatar: string }
// Остальные поля (id, email, role, createdAt) - отброшены

// Для выпадающего списка - только id и имя
type UserOption = Pick<User, 'id' | 'name'>;
// Результат: { id: number, name: string }

// Практический пример: API возвращает только нужные поля
app.get('/api/users/cards', async (req, res) => {
  // Запрашиваем из базы только 2 поля вместо всех 6
  // Это быстрее и экономит трафик
  const users: UserCard[] = await db.query(
    'SELECT name, avatar FROM users'
  );
  res.json(users);
});

Omit - убираем ненужные поля

Omit - это противоположность Pick. Вместо "возьми эти поля" говорим "убери эти поля, остальное оставь":

// Omit<ОткудаБерём, КакиеПоляУбрать>

// Для создания пользователя не нужны id и createdAt
// (id генерирует база данных, createdAt ставится автоматически)
type CreateUser = Omit<User, 'id' | 'createdAt'>;
// Результат: { name: string, email: string, avatar: string, role: 'admin' | 'user' }

// Для отправки данных клиенту убираем секретные поля
type PublicUser = Omit<User, 'role'>;
// Результат: { id: number, name: string, email: string, avatar: string, createdAt: Date }

// Практический пример: функция регистрации
async function register(data: CreateUser) {
  // data содержит name, email, avatar, role
  // Но НЕ содержит id и createdAt - их создаст база данных
  const [result] = await db.execute(
    'INSERT INTO users (name, email, avatar, role) VALUES (?, ?, ?, ?)',
    [data.name, data.email, data.avatar, data.role]
  );
  return result;
}

Record - создаём словарь

Record создаёт тип "словаря" - объект, где ключи и значения имеют определённый тип. Это как настоящий словарь: слово (ключ) и его перевод (значение):

// Record<ТипКлюча, ТипЗначения>

// Словарь переводов: ключ - код языка, значение - текст
type Translations = Record<'ru' | 'en', string>;

const greeting: Translations = {
  ru: 'Привет!',
  en: 'Hello!'
};
// greeting.ru = "Привет!"
// greeting.en = "Hello!"
// greeting.de = ... // Ошибка! "de" не входит в 'ru' | 'en'

// Маппинг статусов заказа на цвета (для отображения на сайте)
type OrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered';

const statusColors: Record<OrderStatus, string> = {
  pending: '#ffa500',     // Оранжевый - ожидает
  processing: '#3498db',  // Синий - обрабатывается
  shipped: '#9b59b6',     // Фиолетовый - отправлен
  delivered: '#00ff88'    // Зелёный - доставлен
};

// Теперь если добавить новый статус, TypeScript напомнит
// что нужно добавить для него цвет. Удобно!

Readonly - запрет на изменение

Иногда нужно убедиться, что данные не будут случайно изменены. Readonly делает все поля доступными только для чтения:

// Readonly запрещает изменение полей после создания

// Конфигурация приложения - не должна меняться после загрузки
type AppConfig = Readonly<{
  apiUrl: string;
  version: string;
  maxRetries: number;
}>;

const config: AppConfig = {
  apiUrl: 'https://api.example.com',
  version: '2.0.0',
  maxRetries: 3
};

// config.apiUrl = 'другой url';  // ОШИБКА!
// TypeScript скажет: "Нельзя менять свойство apiUrl, оно readonly"
// Это защищает от случайных изменений в коде

Комбинируем типы (вот тут начинается магия)

Настоящая сила утилитных типов раскрывается, когда вы их комбинируете:

// Пример 1: Для обновления профиля
// - убираем id и createdAt (их нельзя менять)
// - делаем остальные поля необязательными
type UpdateProfile = Partial<Omit<User, 'id' | 'createdAt'>>;
// Результат: { name?: string, email?: string, avatar?: string, role?: 'admin' | 'user' }

// Пример 2: Для отображения списка пользователей
// - берём только id, name, avatar
// - делаем readonly (только для показа)
type UserListItem = Readonly<Pick<User, 'id' | 'name' | 'avatar'>>;
// Результат: { readonly id: number, readonly name: string, readonly avatar: string }

// Пример 3: Ответ API со списком
// Можно создать переиспользуемый тип для любых списков
type PaginatedList<T> = {
  items: T[];         // Массив элементов
  total: number;      // Всего элементов
  page: number;       // Текущая страница
  hasMore: boolean;   // Есть ли ещё страницы
};

// Используем для разных сущностей
type UserListResponse = PaginatedList<UserListItem>;
type ProductListResponse = PaginatedList<Pick<Product, 'id' | 'name' | 'price'>>;

// В API:
app.get('/api/users', async (req, res) => {
  const response: UserListResponse = {
    items: users,
    total: 150,
    page: 1,
    hasMore: true
  };
  res.json(response);
});

Создаём свои утилитные типы

Когда встроенных типов не хватает, можно создать свои:

// PartialBy - делает необязательными только УКАЗАННЫЕ поля
// (в отличие от Partial, который делает ВСЕ необязательными)
type PartialBy<T, K extends keyof T> = 
  Omit<T, K> & Partial<Pick<T, K>>;

// Для создания пользователя: id необязателен, остальное обязательно
type CreateUser = PartialBy<User, 'id' | 'createdAt'>;
// { name: string, email: string, avatar: string, role: ..., id?: number, createdAt?: Date }

// Nullable - разрешает null для указанных полей
type Nullable<T, K extends keyof T> = {
  [P in keyof T]: P extends K ? T[P] | null : T[P];
};

// Пользователь может не иметь аватарки
type UserWithOptionalAvatar = Nullable<User, 'avatar'>;
// avatar теперь может быть string ИЛИ null

Реальный пример: типизация API

// Описываем все возможные ответы API
type ApiResponse<T> = 
  | { success: true; data: T }              // Успех: есть данные
  | { success: false; error: string };       // Ошибка: есть сообщение

// Функция для запроса к API
async function fetchApi<T>(url: string): Promise<ApiResponse<T>> {
  try {
    const res = await fetch(url);
    const data = await res.json();
    return { success: true, data };
  } catch (err) {
    return { success: false, error: 'Что-то пошло не так' };
  }
}

// Использование
const response = await fetchApi<User[]>('/api/users');

if (response.success) {
  // TypeScript знает: здесь response.data - это User[]
  console.log(response.data[0].name);
} else {
  // TypeScript знает: здесь response.error - это string
  console.error(response.error);
}
// Компилятор не даст обратиться к data в ветке ошибки
// и к error в ветке успеха. Класс!

Итого

Утилитные типы - это не магия, а просто удобные инструменты для трансформации типов. Вот шпаргалка:

  • Partial<T> - все поля необязательные (для форм обновления)
  • Required<T> - все поля обязательные (для валидации конфигов)
  • Pick<T, K> - только выбранные поля (для карточек, списков)
  • Omit<T, K> - все поля кроме указанных (для создания записей)
  • Record<K, V> - словарь с заданными ключами и значениями
  • Readonly<T> - запрет на изменение (для конфигов, констант)

Начните с Pick и Omit - они нужны буквально в каждом проекте. А когда освоитесь, попробуйте комбинации типа Partial<Omit<User, 'id'>> - и почувствуете себя волшебником. Ну, или хотя бы человеком, который не копирует интерфейсы ради изменения одного поля.

Все статьи
Продвинутые Utility Types в TypeScript | Enot Software