Utility-типы TypeScript, которые экономят часы: Record, Omit, ReturnType

Фронтенд3 мин чтения
  • #typescript
  • #utility-types
  • #generics
  • #mapped-types

Большинство разработчиков на TypeScript знают interface и type, но не используют встроенные utility-типы — а они убирают горы дублирования и багов синхронизации. Когда вы копируете интерфейс и потом забываете обновить копию при изменении оригинала — это классический источник ошибок. Utility-типы позволяют вывести один тип из другого, и изменение оригинала автоматически подтягивается всюду.

Базовый набор

Partial<T> — все поля опциональны

Делает все поля типа необязательными. Идеально для форм редактирования, PATCH-запросов, конфигурации с дефолтами:

interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

type UserPatch = Partial<User>;
// Эквивалент: { id?: string; name?: string; email?: string; role?: 'admin' | 'user' }

function updateUser(id: string, patch: UserPatch) {
  // Можно передать только изменившиеся поля
}

Pick<T, Keys> и Omit<T, Keys> — выделить или убрать

Вместо ручного копирования интерфейса (которое рассинхронизируется при изменении User) — выведите часть типа:

// Только публичные данные для отображения
type PublicUser = Pick<User, 'id' | 'name'>;
// { id: string; name: string }

// Всё, кроме чувствительных полей
type SafeUser = Omit<User, 'email' | 'role'>;
// { id: string; name: string }

Измените UserPublicUser и SafeUser обновятся автоматически. Никаких забытых копий.

Record<K, V> — типизированный словарь

Замена кривым конструкциям вроде { [key: string]: User } или, что хуже, any:

// Словарь «id → пользователь»
const usersById: Record<string, User> = {};

// Статус по этапу процесса (ключи ограничены)
type Stage = 'draft' | 'review' | 'published';
const stageLabel: Record<Stage, string> = {
  draft: 'Черновик',
  review: 'На ревью',
  published: 'Опубликован',
};
// Забыли один ключ — ошибка компиляции. Лишний ключ — тоже ошибка.

Readonly<T> — иммутабельная версия

Все поля помечаются readonly, изменять нельзя. Полезно для констант и для защиты от случайной мутации пропсов:

const config: Readonly<ApiConfig> = { baseUrl: '/api', timeout: 5000 };
config.timeout = 3000; // Ошибка компиляции

ReturnType<F> и Parameters<F> — вывести из функции

Если функция уже описана, можно вытащить тип её результата или параметров без дублирования:

function fetchUser(id: string): Promise<User> { /* ... */ }

type UserPromise = ReturnType<typeof fetchUser>;       // Promise<User>
type UserId = Parameters<typeof fetchUser>[0];         // string

Особенно полезно, когда API-клиент типизирован автоматически (например, сгенерирован из OpenAPI-схемы) — типы ответов можно вытаскивать, не описывая их вручную.

Как они устроены: mapped types

Все встроенные utility-типы собраны из двух механизмов — mapped types (преобразование каждого поля) и conditional types (логика на типах).

Partial буквально это:

type Partial<T> = {
  [K in keyof T]?: T[K];
};

Читается: «для каждого ключа K из ключей T — поле с тем же типом T[K], но опциональное (?)». Точно так же Readonly — это [K in keyof T]: readonly T[K]. Pick:

type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};

Поняв mapped types, вы можете писать свои utility-типы под конкретные задачи:

// Сделать все методы объекта асинхронными
type Asyncify<T> = {
  [K in keyof T]: T[K] extends (...args: infer A) => infer R
    ? (...args: A) => Promise<R>
    : T[K];
};

Условные типы и infer

extends в типах работает как if:

type IsString<T> = T extends string ? 'yes' : 'no';

type A = IsString<string>;  // 'yes'
type B = IsString<number>;  // 'no'

infer — вывести тип «по дороге». Часто используется в комбинации:

// Вытащить тип элементов массива
type ElementOf<T> = T extends (infer E)[] ? E : never;

type X = ElementOf<string[]>;    // string
type Y = ElementOf<User[]>;      // User

Практические кейсы

Типизация API-клиента по ответам. Если бэкенд отдаёт данные, а у вас есть тип ответа серверной функции (или сгенерированный из схемы) — выведите типы на клиенте из него, не дублируя интерфейсы:

type CreateUserResponse = Awaited<ReturnType<typeof api.users.create>>;
// Один источник истины — функция api. Всё остальное выводится.

React-пропсы, выведенные из компонентов. Если у компонента есть пропсы, иногда удобнее вытащить их из самого компонента, чем описывать отдельно:

type ButtonProps = ComponentProps<typeof Button>;

Формы редактирования. Partial<T> для незаполненных полей, Pick<T, Keys> для формы, где редактируется подмножество полей. Без ручного переписывания.

Когда utility-типы вредны

Навешивая их слоями, можно получить тип, который невозможно прочитать:

// Стало нечитаемо — проще выписать обычный interface
type Mess = Partial<Record<keyof Omit<User, 'id'>, ReadonlyArray<ReturnType<typeof f>>>>;

Правило: если тип занимает строку и его нельзя понять с первого взгляда — вынесите промежуточные шаги в type-алиасы или, возможно, проще описать обычный interface. Utility-типы — инструмент, а не цель.

Короткое summary

Utility-типы TypeScript (Partial, Pick, Omit, Record, Readonly, ReturnType, Parameters) — это способ выводить типы из других типов вместо ручного копирования, и тем самым убирать баги рассинхронизации. Под капотом они собраны из mapped types и условных типов с infer, которые позволяют описывать собственные преобразования. Главный практический эффект — типизация API-клиента, форм редактирования и React-пропсов по одному источнику истины. Мера: не наслаивать их в нечитаемое месиво — иногда обычный interface понятнее.

Что почитать