Дженерики и утилитарные типы TypeScript: рабочие приёмы
Функция возвращает any, автодополнение молчит, а `Partial` покрывает не тот случай. Разбираем, когда дженерик решает проблему, а когда только усложняет код, и какие встроенные утилиты реально экономят время.

Вы пишете обёртку над fetch, типизируете её как Promise<any> — и теряете подсказки во всём коде, который её вызывает. Или копируете интерфейс User, вручную делаете все поля необязательными для формы редактирования, а потом добавляете поле в оригинал и забываете про копию. Обе проблемы решают дженерики и встроенные утилитарные типы. Ниже — не пересказ справочника, а разбор, где какой инструмент уместен и чем вы платите за каждый. Актуально для TypeScript 5.x.
Дженерик — это не «переменная для типа на всякий случай»
Дженерик оправдан, когда тип результата зависит от типа аргумента. Если такой зависимости нет — дженерик лишний и только маскирует проблему. Классический антипример:
// Плохо: T ничем не связан с возвращаемым значением
function parseJson<T>(raw: string): T {
return JSON.parse(raw);
}
const user = parseJson<User>('{}'); // компилятор верит на слово, проверки нет
Здесь T — не вывод, а утверждение. Вы говорите компилятору «доверься мне», и он отдаёт User без единой проверки. Пустой объект пройдёт как валидный пользователь. Дженерик тут — обёртка вокруг any, только выглядит солиднее.
Настоящий дженерик выводит тип из того, что вы уже передали:
// Хорошо: тип результата выводится из аргумента
function first<T>(items: T[]): T | undefined {
return items[0];
}
const n = first([1, 2, 3]); // number | undefined, без явного указания
const s = first(['a', 'b']); // string | undefined
Разница принципиальна: во втором случае компилятор сам знает T, вы ничего не дописываете, и ошибка типа всплывёт на месте вызова, а не через три файла.
Если вам приходится указывать параметр дженерика руками почти всегда — скорее всего, вывод не работает, и тип нужно строить иначе. Дженерик, который никогда не выводится сам, обычно замаскированный any.
Ограничения через extends
Голый T ничего не знает о своей структуре — вы не можете обратиться к его полям. Ограничение extends говорит компилятору минимум, который гарантированно есть:
// K обязан быть ключом объекта T
function getField<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: 'Аня' };
const id = getField(user, 'id'); // number
const bad = getField(user, 'email'); // ошибка: 'email' нет в keyof T
Возврат типизирован как T[K] — не просто «что-то из объекта», а именно тип того поля, которое запросили. Опечатка в имени ключа ловится на компиляции.
Встроенные утилиты: что реально используется
TypeScript поставляет набор утилитарных типов[1]. На практике из десятков регулярно нужны пять-шесть. Разберём их с ценой, а не просто «делает поля необязательными».
| Утилита | Что делает | Типичный случай | Подводный камень |
|---|---|---|---|
Partial<T> | Все поля необязательные | Объект патча для обновления | Скрывает пропущенные обязательные поля при создании |
Required<T> | Все поля обязательные | Валидация после сборки конфига | Не проверяет значение, только наличие типа |
Pick<T, K> | Оставляет перечисленные поля | DTO с частью полей сущности | При переименовании поля в T ошибка всплывёт здесь |
Omit<T, K> | Убирает перечисленные поля | Тип для создания без id и дат | Опечатка в имени ключа не ловится — Omit принимает любые строки |
Record<K, V> | Объект с ключами K и значениями V | Словарь, справочник, мапа | С string в K даёт небезопасный доступ по любому ключу |
ReturnType<F> | Тип, что возвращает функция | Тип состояния из фабрики | Требует typeof перед именем функции |
Отдельно про две ловушки из таблицы, на которых спотыкаются чаще всего.
Omit не проверяет ключи
До TypeScript 4.x и в текущих версиях Omit по историческим причинам принимает любую строку во втором параметре — компилятор не проверит, что такое поле вообще есть:
type User = { id: number; name: string; email: string };
// Опечатка 'emial' не вызовет ошибку — вы просто ничего не убрали
type Draft = Omit<User, 'emial'>;
Итог: Draft равен полному User, хотя вы думали, что убрали поле. Pick, наоборот, ключи проверяет строго. Если хотите гарантию — можно завести собственный строгий вариант через keyof, но чаще достаточно помнить об этом поведении.
Partial маскирует незаполненные поля
Partial удобен для объекта изменений, но опасен, если им типизировать вход туда, где поля обязаны быть. Тогда компилятор пропустит объект без нужного поля, а падение случится в рантайме.
// Опасно: createUser примет {} без ошибки
function createUser(data: Partial<User>) { /* ... */ }
// Лучше: обязательное отдельно, необязательное через Partial
type CreateUser = Pick<User, 'name' | 'email'> & Partial<Pick<User, 'id'>>;
Собственные утилиты через mapped и conditional types
Когда встроенных не хватает, вы строите свои. Mapped-тип проходит по ключам, conditional выбирает ветку по условию. Пример: сделать все поля объекта доступными только для чтения на любую глубину — встроенный Readonly работает только на верхнем уровне.
// Рекурсивный readonly: вложенные объекты тоже неизменяемы
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? DeepReadonly<T[K]>
: T[K];
};
type Config = { server: { port: number } };
const cfg: DeepReadonly<Config> = { server: { port: 8080 } };
cfg.server.port = 3000; // ошибка: port только для чтения
Цена такой конструкции: рекурсивные типы замедляют проверку и в больших проектах бьют по времени компиляции. Ещё они хуже читаются в подсказках IDE — вместо Config вы видите развёрнутую структуру. Заводите свои утилиты, когда встроенных действительно нет, а не ради элегантности.
Когда что выбирать
- Тип результата зависит от аргумента — дженерик с выводом. Не пишите параметр руками, если можете этого избежать.
- Нужен подтип существующего интерфейса —
PickилиOmit, чтобы правка оригинала автоматически дошла до производных типов. - Объект-словарь —
Record, но для ключей предпочитайте union литералов, а не голыйstring. - Форма/патч с частью полей —
Partial, но никогда не на входе, где поля обязательны. - Встроенных не хватает — mapped/conditional, с оглядкой на время компиляции.
Что делать дальше
Включите строгий режим, если он ещё не включён: "strict": true в tsconfig.json. Без него половина проверок дженериков и Partial просто не срабатывает, и статья теряет смысл. Дальше — посмотрите в lib.es5.d.ts из поставки TypeScript, как реализованы сами Pick и Omit: это лучший разбор mapped-типов на настоящем коде. Если время сборки растёт — измерьте его флагом --extendedDiagnostics у tsc и найдите тяжёлые рекурсивные типы, прежде чем оптимизировать наугад.
[1] Утилитарный тип (utility type) — встроенный обобщённый тип, который трансформирует другой тип: делает поля необязательными, выбирает подмножество ключей и так далее. Полный список зависит от версии TypeScript.
Автоэксперт / механик: Расшифровка OBD2-кодов, выбор б/у авто, диагностика по симптомам.
Спросить за 12 ₽Источники: TypeScript Handbook — Utility Types, TypeScript Handbook — Generics
Частые вопросы
У меня TypeScript 4.x, а не 5.x — примеры сработают?
Дженерик выводится неправильно — возвращает unknown или слишком широкий тип. Что делать?
Почему Omit не ругается на несуществующее поле, а Pick ругается?
Компиляция стала медленной после добавления сложных типов. Это из-за дженериков?
Стоит ли вообще писать свои утилитарные типы или хватит встроенных?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.