Сопоставленные типы: пройти по ключам
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Тип объекта можно не только выписать руками, но и вычислить: пройти по ключам другого типа и выдать на каждый своё свойство. Из этого прохода собраны Partial, Readonly, Record и Pick — никакой встроенной магии там нет, только конструкция со словом in; а из Pick собран и Omit. А начинается всё с ошибки, в которой компилятор сам подсказывает, что писать.
Индексная сигнатура из главы про объекты описывает правило: «любой ключ типа string даёт number». Правило хорошо, пока ключи и правда любые. Но чаще список ключей известен заранее — статусы заказа, коды валют, названия вкладок, — и хочется словарь ровно по этому списку. Первая мысль: подставить объединение литералов туда, где раньше стоял string.
Диагностик две, и первая — TS1337: Тип параметра сигнатуры индекса не может быть типом литерала или универсальным типом. Рекомендуется использовать тип сопоставляемого объекта. Вторая, TS2339, — уже следствие: испорченный тип не содержит ни одного свойства, поэтому обращение к labels.new тоже не проходит.
Обратите внимание, на что именно жалоба. Дело не в объединении как таковом — { [key: string | number]: string } компилятор принимает без единого слова. Мешают литералы. Параметр индексной сигнатуры описывает бесконечное множество возможных ключей, а "new" | "paid" | "shipped" — это конечный перечень из трёх штук. Перечень означает «вот эти три свойства, каждое обязательно», и это совсем другое обещание, чем «что бы вы ни спросили, получите строку».
Вторая фраза диагностики — прямая инструкция. «Сопоставляемый тип объекта» в переводе компилятора — это mapped type, сопоставленный тип. Меняется в коде ровно одно слово.
in вместо двоеточия — и разговор пошёл по существу: Свойство "shipped" отсутствует в типе "{ new: string; paid: string; }" и является обязательным в типе "Labels". Тип перестал быть заглушкой и начал требовать все три ключа. Предупреждение мы, как обычно, проигнорировали — и получили на исполнении undefined вместо подписи.
Практическая ценность как раз в этом: добавили в Status четвёртый статус — и компилятор сам обошёл все словари, где про него забыли.
Внутри фигурных скобок помещается одно выражение вида [K in Keys]: Value. K — имя переменной прохода, любое, как в цикле for..of. Keys — тип-источник ключей, объединение строк, чисел или символов. Value — тип значения, и в нём K уже можно использовать.
| Конструкция | Что она обещает | Когда ключи |
|---|---|---|
{ [key: string]: number } | любой строковый ключ даёт число | заранее неизвестны |
{ [K in Keys]: number } | по одному свойству на каждый ключ из Keys | перечислены в типе |
Самое интересное начинается, когда источником ключей становится keyof T, а типом значения — T[K]: тогда сопоставленный тип не сочиняет объект с нуля, а перерабатывает уже существующий. Вот функция, которая превращает все поля в строки, — и тип, который повторяет её работу на уровне типов.
Ни одной диагностики, и это важнее, чем кажется. У row.price теперь тип string, поэтому padStart разрешён — хотя в исходном Product цена была числом. Список полей при этом не продублирован: добавится четвёртое поле — Formatted подхватит его сам.
Если Value вообще не зависит от T, а ключи задаются отдельно, получается словарь с фиксированным набором ключей. Именно так — в одну строку — устроен Record из стандартной библиотеки.
В lib.es5.d.ts вместо PropertyKey написано keyof any, но это одно и то же — string | number | symbol. Никакого особого статуса у Record нет: это обычный сопоставленный тип, который вы теперь умеете написать сами.
Сопоставленный тип живёт только в типах. В собранном JavaScript от него не остаётся ни строчки — объект всё равно придётся построить руками или циклом.
К проходу по ключам разрешено приписать те же модификаторы, что и обычному свойству: ? после имени ключа и readonly перед ним. Приписываются они сразу ко всем полям — и из этого получаются два самых ходовых утилитных типа.
MyPartial и MyReadonly — это дословно Partial и Readonly из стандартной библиотеки, отличаются только имена переменных прохода. А последние две строки — знакомое по статье про readonly напоминание: запрет живёт в типах, объект base ничем не защищён, и присваивание на исполнении прошло.
Обратное действие пишется через минус. -? снимает необязательность, -readonly снимает запрет на запись. Плюс тоже существует (+?, +readonly), но его никто не пишет: он и так подразумевается.
Первый тип — это Required из библиотеки, второй в неё не входит, хотя нужен постоянно: после as const или после чужого Readonly объект бывает нужно всё-таки собрать по кусочкам. Своего Mutable в стандартной библиотеке нет — пишут его сами, вот этой одной строкой.
Строка defaults.theme.length заслуживает отдельного взгляда. Поле было объявлено как theme?: "light" | "dark", то есть могло оказаться undefined, и обращение к .length без проверки не прошло бы. После -? проверка не нужна: вопрос снят вместе с undefined.
Тонкость, на которой спотыкаются: -? убирает undefined только тогда, когда оно попало в тип из самого вопросительного знака. Если undefined написано в типе значения руками, оно остаётся.
Ни одной диагностики: у a поле стало просто string, у b осталось string | undefined — обязательным, но по-прежнему способным быть пустым. Разница между «поля может не быть» и «поле есть, а в нём undefined» никуда не делась, и -? её не стирает.
До сих пор ключ переезжал в новый тип под своим именем. Приписка as после in позволяет его подменить: слева от неё — исходный ключ, справа — тип, который станет ключом в результате. Это третье значение слова as в языке, рядом с as const и утверждением типа, и с ними оно никак не связано.
Самое неожиданное применение — не переименование, а удаление. Если справа от as получается never, свойство просто не попадает в результат.
Условный тип внутри as — тот самый, с которого началась глава: для каждого ключа спрашиваем, входит ли он в список на выброс, и отвечаем либо never, либо самим ключом. Получившийся Without делает то же, что Omit, — но заглянуть в библиотеку всё равно стоит: там Omit собран из двух знакомых утилит, type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>. Сначала Exclude выбрасывает ненужные имена из объединения ключей, потом Pick проходит по остатку. Два прохода вместо одного — и тот же результат.
Настоящее переименование выглядит так же, но справа от as стоит не условие, а преобразование строки. Простейшее — встроенное Uppercase.
K & string здесь не украшение. keyof T может содержать числа и символы, а Uppercase работает только со строками; пересечение отбрасывает всё лишнее. Диагностика на последней строке получилась говорящая: компилятор не просто отказал, а назвал новое имя ключа.
Переименование в as ограничено только тем, что вы умеете вычислять из строкового типа. Пока это Uppercase и его родня — но настоящий простор дают шаблонные литеральные типы, о них следующая статья.
У прохода вида [K in keyof T] есть особенность, о которой не догадываешься, пока не наткнёшься. Такой проход компилятор считает не построением нового объекта, а копированием старого — и старается сохранить его форму. Проверим на массиве и кортеже.
Ни одной диагностики. Хотя по букве кода keyof number[] — это number | "length" | "push" | "map" | ..., и честный проход по всем ключам дал бы объект с полем length типа number[]. Компилятор поступил иначе: узнал в T массив, обошёл только элементы и вернул number[][]. То же с кортежем — длина и порядок сохранились.
А теперь добавим as K. Ключ остаётся тот же самый, переименования нет — казалось бы, ничего не изменится.
Изменилось всё: Тип "number[][]" не может быть назначен для типа "BoxedRenamed<number[]>", а вложенная причина называет виновника — Типы свойства "length" несовместимы. Тип "number" не может быть назначен для типа "number[]". Стоило появиться приписке as, и компилятор перестал считать проход копированием: он послушно обошёл все ключи массива, включая length, и завернул каждый в массив.
Отсюда правило, которое стоит помнить: [K in keyof T] без as — это «то же самое, но с изменёнными значениями», а с as — «новый объект, собранный по ключам». Если ваш утилитный тип должен работать и с массивами, as в нём лишний.
Не всё, впрочем, теряется. readonly и ? исходных полей копируются в обоих случаях — здесь as ничего не ломает.
Две одинаковые диагностики TS2540, и обе копии не потребовали email. Так что as отменяет не наследование модификаторов, а особое обращение с массивами, кортежами и объединениями.
- Индексная сигнатура задаёт правило для любого ключа; для конечного перечня ключей есть отдельная конструкция, и TS1337 прямо на неё указывает.
- Сопоставленный тип — это
{ [K in Keys]: Value }: по одному свойству на каждый ключ изKeys, причёмKдоступно и в типе значения. Partial,Readonly,RequiredиRecord— обычные сопоставленные типы в одну строку, их можно написать самому;Omitсобран изPickиExclude.- Модификаторы
?иreadonlyприписываются ко всем полям сразу, а минус (-?,-readonly) их снимает. - Приписка
asподменяет ключ;neverна его месте выбрасывает свойство целиком. - Проход
[K in keyof T]безasсохраняет форму источника — массив остаётся массивом; сasэто уже не копия, а новый объект по ключам.
1/4Тип Status — это "new" | "paid". Почему запись { [key: Status]: string } вызывает TS1337?
2/4Что делает минус в записи { [K in keyof T]-?: T[K] }?
3/4type Boxed<T> = { [K in keyof T]: T[K][] }. Каким получится Boxed<number[]>?
4/4В { [K in keyof T as ...]: T[K] } выражение справа от as для какого-то ключа дало never. Что окажется в результирующем типе?
Снять readonly
Тип Config закрыт на запись целиком, а собрать настройки нужно по частям. Напишите Mutable<T> — тот же объект, но со снятым readonly у всех полей. В стандартной библиотеке такого типа нет, поэтому его пишут сами.
Модификатор снимается минусом перед ним. Всё остальное — обычное копирование: ключи берём из keyof T, значения из T[K].
Диагностика исчезла. Важно помнить, что снятие readonly ничего не меняет на исполнении: запрет и раньше существовал только в типах.
Свой Omit
У заказа есть служебное поле, которое нельзя отдавать наружу. Напишите Without<T, Drop> — тот же тип без перечисленных ключей. Готовым Omit пользоваться нельзя: задача как раз в том, чтобы собрать его самому.
Каждый ключ пропускаем через условный тип: попал в Drop — отдаём never, и свойство исчезает; не попал — отдаём ключ как есть.
Ограничение Drop extends keyof T — не формальность: с ним опечатка в имени ключа станет ошибкой. У библиотечного Omit такого ограничения нет, и опечатка в нём проходит молча.
Только строковые поля
Условие в as может смотреть не только на имя ключа, но и на тип значения. Напишите StringFields<T> — тип, в котором остались только поля со строковыми значениями.
Условие переезжает с ключа на значение: спрашиваем не «этот ли это ключ», а «строка ли в нём лежит».
Тот же приём с заменой string на Function отделяет методы от данных — это популярный способ описать «только поля» или «только действия» чужого объекта.