Partial, Pick, Omit, Record, ReturnType, Awaited
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Половина типов, которые пишут руками, уже написана в стандартной библиотеке: Partial, Pick, Omit, Record, ReturnType, Awaited. Ни одна из них не ключевое слово и не магия компилятора — это обычные дженерик-типы длиной в строку-две, которые можно открыть и прочитать. Прочитанное определение сразу говорит и что утилита умеет, и чего от неё не дождёшься.
Функция обновления настроек принимает не весь объект, а его кусок: пользователь поменял размер шрифта — про тему и перенос строк в этом вызове не сказано ничего. Описать «кусок» отдельным типом руками несложно, но это копия, которая разойдётся с оригиналом на первом же новом поле.
Последняя строка ловит TS2322: Тип "string" не может быть назначен для типа "boolean | undefined". Необязательность — единственное, что Partial поменял: поле wrap теперь можно не писать вовсе, но написать в него что попало по-прежнему нельзя.
Особого статуса у Partial нет. Он объявлен в lib.es5.d.ts — файле, который компилятор подкладывает к любому проекту, — а всё объявление — одно выражение в фигурных скобках. Его можно переписать себе и получить неотличимый тип.
[P in keyof T] читается как «пройти по всем ключам T», ? — «сделать необязательным», T[P] — «оставить тот же тип значения», тот самый индексный доступ из прошлой статьи. Механику сопоставленных типов разбираем в следующей главе, а здесь важнее обратное: из этой строки видно, чего Partial не делает.
Он идёт по ключам верхнего уровня и на этом останавливается. Вложенный объект переносится как есть — со всеми своими обязательными полями.
TS2741: Свойство "size" отсутствует в типе "{ font: string; }". Само поле editor стало необязательным — но раз уж вы его пишете, объект внутри должен быть полным. Готового «глубокого» Partial в стандартной библиотеке нет: такой пишется рекурсивным сопоставленным типом, и это тема главы про условные и сопоставленные типы.
Partial ничего не меняет в объекте — он строит новый тип. В скомпилированном JavaScript от него не остаётся ни следа.
Необязательное поле — это поле, у которого в типе значения появился undefined. Значит, передать undefined явно компилятор разрешит: формально вы не нарушили ничего. А расширение объекта такое значение честно скопирует поверх умолчания.
Компилятор молчит, падает исполнение: TypeError: Cannot read properties of undefined. Лечится это либо руками (user.theme ?? DEFAULTS.theme), либо настройкой exactOptionalPropertyTypes, которая разводит «поля нет» и «поле есть, и в нём undefined»; до настроек компилятора доберёмся в главе про реальный проект.
Из одного типа сущности почти всегда вырастает несколько: полная запись, урезанная карточка для списка, безопасный вид для отдачи наружу. Pick перечисляет, что оставить, Omit — что выбросить.
Строка с leak — TS2353: Объектный литерал может использовать только известные свойства. "passwordHash" не существует в типе "PublicUser". Поле не помечено «не показывать», его в типе просто нет, и проверка лишних свойств из главы про структурную типизацию срабатывает на нём как на чужом.
Выглядят эти двое парой, но устроены по-разному, и разница вылезает на первой же опечатке.
Диагностика ровно одна, на второй строке: TS2344: Тип ""nmae"" не удовлетворяет ограничению "keyof User" — двойные кавычки не опечатка, так компилятор оформляет имя литерального типа. На Omit — тишина: тип получился полным, и ошибку вы найдёте не раньше, чем заметите почту там, где её быть не должно.
Ответ снова в определениях: type Pick<T, K extends keyof T> против type Omit<T, K extends keyof any>. У Pick второй параметр ограничен ключами T, у Omit — «чем угодно, что вообще бывает ключом», то есть string | number | symbol. Так задумано: Omit разрешают применять и к типам, где такого ключа может не быть. Цена свободы — непойманная опечатка.
Перечисляйте то, чего меньше. Но помните про строгость: Pick сломается сразу, если поле в исходном типе переименовали, а Omit молча вернёт лишнее.
Полное определение Omit — Pick<T, Exclude<keyof T, K>>, и главное слово в нём keyof. У объединения ключами считаются только те, что есть во всех вариантах сразу: спросить у Guest | Member про email нельзя, пока неизвестно, какой это вариант.
TS2339: Свойство "email" не существует в типе "Anon" — присваивание прошло, а прочитать поле уже нельзя: Anon схлопнулся в пустой тип. Чтобы применить утилиту к каждому варианту по отдельности, нужен распределяющий условный тип, и это тоже следующая глава.
Record вы уже применяли как словарь — Record<string, number> вместо индексной сигнатуры. В этой главе у него другая работа: набор ключей не обязан быть написан руками, его можно вычислить из существующего типа.
TS2741: Свойство "age" отсутствует в типе "{ id: string; name: string; email: string; }" и является обязательным в типе "Record<keyof User, string>". Таблица подписей перестала жить своей жизнью: добавили поле в User — компилятор сам привёл вас туда, где для него нет подписи.
Определение и здесь короткое: type Record<K extends keyof any, T> = { [P in K]: T }. Отсюда сразу два следствия. Ключи обязательны все до одного — просто потому, что в строке нет ?. И утилиты складываются друг с другом: Record отдаёт обычный тип объекта, а Partial принимает любой тип.
Partial<Record<Status, number>> — честная запись «таблица, которая заполняется постепенно»: тип значения стал number | undefined, и без ?? прибавить единицу компилятор не даст. Именно так стоит описывать счётчики, к которым добавляют по ключу за раз.
Record<keyof T, X> и Record<Объединение, X> дают проверку полноты. Record<string, X> — нет: там ключи произвольные, проверять нечего.
Функция уже описывает форму своего результата — телом. Записать рядом тип, повторяющий ту же форму, значит завести вторую копию правды и обречь себя следить за обеими. ReturnType и Parameters достают из функции её собственные типы.
Четвёртая строка — TS2749: "makePoint" относится к значению, но здесь используется как тип. Возможно, вы имели в виду "typeof makePoint"? Компилятор угадал верно: ReturnType ждёт тип функции, а makePoint — значение. Мост между мирами один — typeof из первой статьи главы, и без него утилиты по функциям не работают.
Parameters отдаёт кортеж — [number, number], а не объект; отсюда и расширение makePoint(...args) в последней строке. Но чаще нужен всё-таки ReturnType, и особенно там, где тип возврата не написан нигде: компилятор вывел его сам, а вам нужно то же самое имя для этого типа.
У parseQuery нет ни одной аннотации возврата, и всё равно describe знает про count и params. Добавите в возвращаемый объект поле — Query получит его сам; уберёте count — describe перестанет компилироваться в ту же секунду.
Дженерик-функция — это не один тип, а семейство: пока T не подставлен, отвечать нечего. ReturnType в такой ситуации не отказывается работать, а возвращает unknown.
TS18046: "value" относится к типу unknown — ровно та дверь из системы типов, про которую была статья про any и unknown: положить внутрь можно всё, достать без проверки — ничего. Вытащить тип из дженерик-функции можно, но для этого нужен infer из следующей главы.
С асинхронной функцией ReturnType честен до неудобства: она возвращает промис, его он и отдаёт.
TS2353 с говорящим хвостом: "id" не существует в типе "Promise<{ id: number; name: string; roles: string[]; }>". Компилятор не ошибся — в Loaded действительно промис. До нужного типа не хватает одного шага: снять обёртку, и это Awaited.
Две диагностики говорят одно и то же с разных сторон: Тип "string" не может быть назначен для типа "number" — значит, обе обёртки сняты; Тип "number" не может быть назначен для типа "string" — значит, с непромисом Awaited не делает ничего. Он повторяет в типах поведение самого await, вплоть до рекурсии, и потому в его определении стоит условный тип, а не одна строка.
В связке с индексным доступом получается приём, ради которого эту главу и писали: тип одного элемента ответа, ни разу не описанный руками.
Три шага в одну строку — и ни одного поля, написанного дважды. Поменяется тело loadOrders, и renderOrder перестанет компилироваться ровно там, где перестал быть правдой.
Awaited нужен не только для async: то же самое достаёт тип из любого значения-промиса, включая чужие функции, которые вернули Promise<Promise<T>>.
Утилит в стандартной библиотеке пара десятков, и почти все — такие же короткие определения. Вот те, что встречаются в реальном коде чаще прочих.
| Утилита | Что делает | Определение |
|---|---|---|
Partial<T> | все поля необязательные | { [P in keyof T]?: T[P] } |
Required<T> | все поля обязательные | { [P in keyof T]-?: T[P] } |
Readonly<T> | все поля только для чтения | { readonly [P in keyof T]: T[P] } |
Pick<T, K> | оставить перечисленные ключи | { [P in K]: T[P] } |
Omit<T, K> | выбросить перечисленные ключи | Pick<T, Exclude<keyof T, K>> |
Record<K, T> | объект с ключами K и значениями T | { [P in K]: T } |
Exclude<T, U> | убрать из объединения варианты, подходящие под U | T extends U ? never : T |
Extract<T, U> | оставить только их | T extends U ? T : never |
NonNullable<T> | убрать null и undefined | T & {} |
Parameters<T> | кортеж параметров функции | условный тип с infer |
ReturnType<T> | тип возврата функции | условный тип с infer |
Awaited<T> | снять все слои Promise | рекурсивный условный тип |
Половина колонки «определение» пока выглядит шифром — in, extends ? : и infer разбираются в следующей главе, и пользоваться утилитами это не мешает. Две из таблицы стоит попробовать прямо сейчас: они закрывают самые частые бытовые нужды.
Required снял ? со всех полей сразу — TS2741 на неполном объекте и никаких проверок на undefined при чтении. NonNullable вырезал из объединения null и undefined, поэтому null в него уже не кладётся: TS2322. Это тот же приём, что и ручное сужение, только выраженный типом.
Общее правило простое: прежде чем писать тип руками, посмотрите, не выводится ли он из того, что уже есть. А если утилита повела себя не так, как вы ждали, ответ лежит в её определении, а не в документации — в редакторе до него один переход «Перейти к определению» по имени утилиты.
- Утилиты вроде Partial и Pick — не ключевые слова, а обычные дженерик-типы из lib.es5.d.ts; их определение можно открыть и прочитать.
- Partial делает необязательными только поля верхнего уровня: вложенный объект переносится целиком, со всеми обязательными полями.
- Pick ограничен ключами исходного типа и ловит опечатку, Omit принимает любой ключ и на опечатке молчит.
- Record строит объект по набору ключей, а набор можно вычислить: Record<keyof T, X> заставляет описать все поля и не забыть новое.
- ReturnType и Parameters работают с типом функции, поэтому им почти всегда нужен typeof; у async-функции результат — Promise, и его снимает Awaited.
- Утилиты складываются друг с другом: Partial<Record<K, V>>, Awaited<ReturnType<typeof f>>[number] — это по-прежнему обычные типы.
1/4В Pick<User, "nmae"> и в Omit<User, "emial"> одинаковая опечатка в имени поля. Что скажет компилятор?
2/4Тип Settings — это { theme: string; editor: { font: string; size: number } }. Подойдёт ли значение { editor: { font: "Menlo" } } типу Partial<Settings>?
3/4Функция объявлена как async function loadUser(id: number) и возвращает объект пользователя. Что представляет собой ReturnType<typeof loadUser>?
4/4Вы написали у себя в проекте type MyPartial<T> = { [P in keyof T]?: T[P] } и подставили MyPartial<User> туда, где ждали Partial<User>. Что будет?
Черновик товара
Тип ProductPatch описывает изменения товара: id обязателен (иначе непонятно, что менять), остальные поля — любые из Product и все необязательные. Сейчас он переписан руками и уже разошёлся с оригиналом. Выразите его через утилиты, чтобы расхождение стало невозможным.
Нужное описывается пересечением двух утилит: Pick оставляет обязательный id, а Partial<Omit<...>> добирает остальные поля необязательными.
Последняя строка теперь ошибка — TS2322 с причиной Свойство "id" отсутствует в типе "{ price: number; }" и является обязательным в типе "Pick<Product, "id">". И заодно исчезла возможность соврать про тип price: он берётся из Product, а не пишется заново.
Тип из асинхронной функции
Тип Stats написан руками и уже отстал от loadStats: поле lastSeen в него не доехало, и render не компилируется. Почините не дописыванием поля, а так, чтобы Stats следовал за функцией сам.
ReturnType<typeof loadStats> даст Promise, поэтому обёртку снимаем Awaited. Одна строка вместо перечисления полей.
Проверьте себя: уберите из возвращаемого объекта visits — и render сломается сразу, вместо того чтобы напечатать undefined.
Подписи для всех полей
Словарь подписей объявлен как Record<string, string>, поэтому забытый ключ никого не смущает — renderRow("minutes") молча печатает undefined. Измените тип labels так, чтобы про забытую подпись сказал компилятор.
Ключи словаря должны быть не «любой строкой», а ключами Article: Record<keyof Article, string>.
Теперь на объявлении labels — TS2741: Свойство "minutes" отсутствует в типе "{ slug: string; title: string; }" и является обязательным в типе "Record<keyof Article, string>". Дописали подпись — ошибка ушла; добавили в Article новое поле — ошибка вернулась.