Утилитарные типы в TypeScript: что учесть на практике
Partial, Pick, Omit и Record выглядят удобно, но легко превращают типы в мусор. Разбираем, где встроенные утилиты помогают, а где скрывают ошибки и ломают вывод типов.

Вы описали DTO пользователя, а потом функция обновления попросила «то же самое, но всё необязательное». Рука тянется к Partial<User>. Через полгода этот Partial протаскивает undefined в базу, а редактор молча соглашается. Утилитарные типы TypeScript экономят строки, но каждый из них меняет контракт — и не всегда так, как вы думаете. Разберём, что именно они делают с типом и где это выходит боком. Ориентируюсь на TypeScript 5.x.
Что вообще делают эти утилиты
Утилитарные типы (utility types) — это дженерики из стандартной библиотеки lib.es5.d.ts и соседних файлов, которые собирают новый тип из существующего. Большинство из них — обёртки над mapped types и keyof, ничего магического внутри нет.
Самые ходовые:
Partial<T>— все свойства становятся необязательными;Required<T>— все становятся обязательными;Readonly<T>— все становятсяreadonly;Pick<T, K>— оставить только ключиK;Omit<T, K>— убрать ключиK;Record<K, V>— объект с ключамиKи значениямиV;Exclude,Extract,NonNullable— операции над объединениями (union).
Проблемы начинаются не с того, что они делают, а с того, чего они не проверяют.
Partial: удобно, пока не дойдёт до записи
Классический случай — функция частичного обновления:
interface User {
id: number;
name: string;
email: string;
}
// Обновляем произвольное подмножество полей
function updateUser(id: number, patch: Partial<User>) {
// patch.name имеет тип string | undefined
}
Сигнатура читается хорошо. Но Partial не отличает «поле не передали» от «поле передали как undefined». Вызов updateUser(1, { name: undefined }) пройдёт проверку типов. Если внутри вы делаете Object.assign в сущность или пишете в базу без фильтрации, name затрётся в undefined.
Второй подвох — Partial не рекурсивен. Вложенные объекты остаются обязательными:
interface Settings {
theme: { color: string; contrast: number };
}
// Тут theme необязательный, но если он есть —
// внутри color и contrast снова обязательны
type PartialSettings = Partial<Settings>;
Своего DeepPartial в стандартной библиотеке нет. Если он нужен, пишите рекурсивный mapped type сами или берите из проверенной библиотеки — не полагайтесь на то, что Partial «наверное, зайдёт вглубь».
Partial отвечает на вопрос «какие поля можно опустить», а не «какие значения допустимы». Для валидации входных данных это разные вещи, и путать их — источник тихих багов.
Omit почти не проверяет ключи
Omit<T, K> устроен через Pick<T, Exclude<keyof T, K>>. Ключевой момент: параметр K объявлен как keyof any, то есть string | number | symbol, а не keyof T. Из-за этого опечатка в имени ключа не вызовет ошибку:
interface Article {
id: number;
title: string;
body: string;
}
// 'titel' — опечатка, но компилятор молчит
type Preview = Omit<Article, 'titel'>;
// Preview === { id, title, body } — ничего не убралось
Сравните с Pick: у него K extends keyof T, поэтому Pick<Article, 'titel'> сразу подсветится красным. Если хотите такой же строгости от Omit, придётся объявить свою версию:
type StrictOmit<T, K extends keyof T> =
Pick<T, Exclude<keyof T, K>>;
// Теперь опечатка в ключе — ошибка компиляции
type Preview2 = StrictOmit<Article, 'titel'>; // Error
Ещё Omit ломается на union-типах: он применяется к объединению целиком и оставляет только общие ключи, а не проходит по каждому члену. Для дискриминированных объединений это почти всегда не то, что вам нужно.
Record: индексная подпись, а не «словарь с этими ключами»
Record<string, User> — это { [key: string]: User }. Проблема не в самом типе, а в ожиданиях. При обращении по ключу TypeScript считает, что значение есть, хотя по факту его может не быть:
const usersById: Record<string, User> = {};
const u = usersById['404'];
// u имеет тип User, а не User | undefined —
// но в рантайме тут undefined
Спасение — флаг noUncheckedIndexedAccess в tsconfig.json. С ним доступ по ключу возвращает User | undefined, и компилятор заставит проверить наличие. Флаг не включён в strict по умолчанию, включайте отдельно.
Когда набор ключей конечный и известный, Record с union-литералом полезнее и надёжнее обычного объекта:
type Role = 'admin' | 'editor' | 'viewer';
// Компилятор потребует описать все три роли
const permissions: Record<Role, string[]> = {
admin: ['read', 'write', 'delete'],
editor: ['read', 'write'],
viewer: ['read'],
};
Забыли viewer — ошибка. Это отличный способ не потерять ветку при добавлении новой роли.
Сводка: что учесть по каждой утилите
| Утилита | Что делает | Что учесть |
|---|---|---|
| Partial<T> | все поля опциональны | не рекурсивна; пропускает явный undefined |
| Required<T> | все поля обязательны | снимает и ?, и приведённый undefined из типа значения |
| Pick<T, K> | оставить ключи K | K проверяется по keyof T — безопасно |
| Omit<T, K> | убрать ключи K | K не проверяется; ломается на union |
| Record<K, V> | объект ключ→значение | доступ по ключу не даёт undefined без флага |
| Readonly<T> | поля только для чтения | поверхностная; вложенные объекты мутабельны |
Когда что выбирать
- Подмножество полей известно заранее —
Pick, а неOmit. Явно перечислить, что нужно, надёжнее, чем перечислять, что выкинуть: при добавлении нового поля в исходный типPickего не подхватит автоматически, аOmit— подхватит, и это не всегда желаемо. - Частичное обновление сущности —
Partial, но с явной фильтрациейundefinedв рантайме перед записью. Тип не заменяет проверку. - Конечный набор ключей (роли, статусы, вкладки) —
Recordс union-литералом. Компилятор проследит за полнотой. - Произвольный словарь —
Record<string, V>плюсnoUncheckedIndexedAccess, иначе получите ложную уверенность в наличии значения.
Что ломается чаще всего
Readonly не защищает вглубь. Readonly<Settings> запретит переприсвоить settings.theme, но settings.theme.color = 'red' пройдёт. Для неизменяемости данных в редьюсерах и сторах поверхностный Readonly даёт ложное чувство безопасности.
Комбинации утилит теряют читаемость типов. Partial<Omit<Pick<User, 'name' | 'email'>, 'email'>> компилируется, но в подсказках IDE и сообщениях об ошибках разворачивается в кашу. Если цепочка длиннее двух звеньев — вынесите промежуточный именованный type и дайте ему смысл.
Расширение через intersection не переопределяет поле. Попытка «заменить» тип поля через User & { id: string } даст id: number & string, то есть never. Чтобы реально поменять тип поля, сначала Omit старое, потом добавьте новое: Omit<User, 'id'> & { id: string }.
Что делать дальше
Проверьте свой tsconfig.json: включён ли strict, и добавлен ли отдельно noUncheckedIndexedAccess — без него Record и индексный доступ обманывают. Заведите в проекте пару собственных утилит там, где встроенные слабы: StrictOmit и, при необходимости, DeepPartial. Дальше стоит посмотреть в сторону template literal types и satisfies — они закрывают случаи, где утилитарных типов уже не хватает. Полный список встроенных утилит и их сигнатуры смотрите в разделе Utility Types документации TypeScript, там же указано, с какой версии появилась каждая.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: TypeScript Handbook — Utility Types, TypeScript Handbook — Mapped Types
Частые вопросы
У меня TypeScript 4.x, эти утилиты работают?
Почему Omit не ругается на несуществующий ключ, а Pick ругается?
Как сделать Partial рекурсивным?
Partial добавил undefined и теперь в базу пишется undefined. Как правильно?
Стоит ли включать noUncheckedIndexedAccess на большом существующем проекте?
Чем заменить длинную цепочку утилит вроде Partial<Omit<Pick<...>>>?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.