Выводить, а не дублировать
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Список вкладок лежит в массиве, а union их идентификаторов написан руками — рано или поздно эти два описания расходятся, и компилятор не скажет об этом ни слова. Приёмы всей главы — as const, typeof, keyof, индексный доступ и утилиты — существуют ради одного правила: у сущности одно описание, всё остальное выводится из него.
Почти любое приложение устроено одинаково: где-то лежат данные — список вкладок, ролей, статусов, — а где-то тип, перечисляющий их же. Пока список короткий, дублирование выглядит безобидной формальностью.
Диагностика одна: Аргумент типа ""map"" нельзя назначить параметру типа "Tab", это TS2345. Странность в том, что вызов правильный — вкладка map в данных есть, у неё есть заголовок, и после запуска в консоли появилась «Карта». Ошибся не вызов, а тип: он описывает мир, которого уже нет.
Вторая половина беды выглядит противоположно и обходится дороже. Если описания разъехались в другую сторону, компилятор промолчит вовсе.
Ни одной диагностики. Тип обещает вкладку chart, данные о ней не знают, titleOf честно отдаёт заглушку. Сборка зелёная, типы проверены — а в интерфейсе пустая вкладка.
Дело не в лени: переписать три строки недолго. Дело в том, что для компилятора Tab и TABS — два независимых объявления, между которыми нет никакой связи, и сверять их он не станет. Значит, сверять надо не глазами, а сделать одно из другого.
Копии описаний расходятся молча. Компилятор сторожит только те связи, о которых ему сказали.
Массив с данными старше типа: он нужен для рендера в любом случае, а тип придуман, чтобы его описать. Значит, источником назначаем массив, а тип получаем как производное — тремя знакомыми по этой главе шагами.
Выражение (typeof TABS)[number]["id"] читается слева направо. typeof TABS — тип значения, здесь это кортеж из трёх объектов с литеральными полями (за литералы отвечает as const). [number] — индексный доступ по числовому ключу, то есть «любой элемент», объединение трёх объектов. ["id"] берёт у каждого поле id, и получается union трёх строк.
Компилятор ответил Аргумент типа ""chart"" нельзя назначить параметру типа ""map" | "list" | "board"" и заодно показал содержимое union — ровно те три идентификатора, что лежат в массиве. Без as const фокус не сработал бы: у обычного массива поле id выводится как string, и Tab оказался бы просто string — проверкой, которая ничего не проверяет.
Настоящая польза видна не в момент написания, а в момент правки. Добавили в массив ещё одну вкладку — тип пересчитался сам, и вместе с ним изменились все места, где он используется.
TS2741: Свойство "map" отсутствует в типе "{ list: string; board: string; }" и является обязательным в типе "Record<"map" | "list" | "board", string>". Компилятор указал на объект, который не успел за источником. Это и есть та работа, ради которой всё затевалось: правка в одном месте превращается в список дел в остальных.
Хороший производный тип превращает «не забыть обновить» в ошибку сборки.
Форма — самый частый случай, где описаний заводится сразу три: список полей для рендера, тип значений, тип ошибок. Хватает первого.
От одного массива пошли три типа: Field — имена полей, FormValues — по строке на каждое, FormErrors — сообщение, может быть, у какого-то. Внутри цикла field имеет тип Field, а не string, поэтому values[field] проходит без TS7053 из статьи про индексные сигнатуры: компилятор знает, что такой ключ есть наверняка. А выдуманное поле ловится сразу — Свойство "adress" не существует в типе "FormValues", TS2339.
Иногда источником удобнее сделать не список имён, а объект-образец: пустую модель, с которой начинается форма, или набор значений по умолчанию. Тогда к typeof добавляется keyof, и одно значение раздаёт типы всему модулю.
Опечатку в патче поймал TS2353: Объектный литерал может использовать только известные свойства. "nmae" не существует в типе "Partial<{ id: number; name: string; email: string; active: boolean; }>". А запуск показал, чем это обходится без типов: в квадратных скобках пусто. Ключ nmae спокойно лёг в объект рядом с name, имя пользователя осталось прежним, и никто не заметил.
Обратите внимание на порядок: сначала появилось значение, которое всё равно нужно программе, и только потом типы. Придумывать значение ради вывода типов не надо — если источником просится тип, пусть источником и будет тип.
Не всякая правда лежит в константе. Часто настоящее описание — это функция: фабрика начального состояния, загрузчик данных, конструктор объекта. Тогда тип снимают с её результата.
ReturnType<typeof createState> работает в два приёма: typeof превращает функцию-значение в тип функции, ReturnType достаёт из него тип возврата. Дописали в фабрику поле — оно тут же появилось у всех, кто работает с State, и первым сломалось восстановление: Свойство "open" отсутствует в типе "{ items: string[]; count: number; }". Ровно то место, куда и нужно было заглянуть.
С асинхронной функцией в цепочке появляется лишнее звено — обещание. Его видно, стоит попробовать использовать тип возврата напрямую.
ReturnType<typeof loadUser> — это Promise<{ id: number; name: string; tags: string[]; }>, а не пользователь: async-функция возвращает обещание, и тип честно это повторяет. Компилятор так и сказал — "id" не существует в типе "Promise<…>". Развернуть обёртку — работа Awaited, и именно поэтому в паре с ReturnType он встречается чаще, чем сам по себе.
| Что нужно | Источник | Как пишется |
|---|---|---|
| Тип конфига | само значение | typeof CONFIG |
| Union допустимых значений | массив с as const | (typeof TABS)[number] |
| Union ключей | тип модели | keyof User |
| Тип одного поля | тип модели | User["email"] |
| Черновик или патч | тип модели | Partial<User> |
| Таблица по ключам | union ключей | Record<Field, string> |
| Тип состояния | фабрика | ReturnType<typeof createState> |
| Тип загруженных данных | async-функция | Awaited<ReturnType<typeof loadCart>> |
Рядом с ReturnType живёт Parameters: он отдаёт кортеж типов аргументов. Пригождается там, где вызов откладывают — очередь заданий, повтор после ошибки, обёртка с логированием.
Кортеж из статьи про массивы здесь работает по назначению: длина известна, порядок типов известен, и sendMessage(...args) раскладывается по параметрам без единого приведения. Поменяется сигнатура функции — поменяется и содержимое очереди.
У приёма есть цена, и первый счёт приходит в тот момент, когда вы ошибаетесь в самом источнике. Выведенный тип не проверяет значение — он его повторяет.
Компилятор отверг правильное слово: Аргумент типа ""board"" нельзя назначить параметру типа ""list" | "boadr"". Опечатка попала в данные, стала частью типа и теперь защищена системой типов. Читать такую диагностику надо не как «ошибка в вызове», а как отчёт о содержимом источника — привычка из статьи про чтение диагностики: смотреть не только на подчёркнутую строку.
Вторая цена — расстояние. Поправили одну букву в константе, получили десяток ошибок в файлах, которые сегодня не открывали. Сделка выгодная (без вывода этих ошибок не было бы вовсе, был бы тихий сбой), но список надо читать снизу вверх: чинить не каждую строчку по очереди, а источник или самое первое несоответствие.
Третья граница — публичный контракт. Тип, выведенный из реализации, меняется вместе с реализацией: кто-то переименовал поле во внутреннем объекте, и у всех снаружи поехали типы. Пока источник и его потребители лежат в одном модуле, это польза. Как только тип уходит за границу модуля или пакета, его пишут руками, а реализацию проверяют на соответствие ему.
Внутри модуля источник — значение. На границе модуля источник — тип, а значение под него подгоняется.
Когда первичен тип, значение к нему прижимают аннотацией — как icons из второго раздела. Проверка полноты работает, но за неё берут плату: тип переменной становится тем, что написано в аннотации, а не тем, что лежит в объекте.
titles.list — это string, литерал «Список» потерян. Обычно всё равно, но не тогда, когда из этого же объекта хочется вывести ещё один тип. Для таких случаев есть оператор satisfies: он проверяет значение типом, ничего не огрубляя, — ему посвящена статья «satisfies: проверить, не потеряв вывод» в главе про границы программы.
- Два описания одной сущности расходятся молча: компилятор не знает, что массив данных и написанный руками union связаны.
- Источником назначают то, что программе нужно в любом случае, — массив, объект-образец, фабрику; остальные типы выводят из него.
as constплюсtypeofплюс индексный доступ превращают данные в union:(typeof TABS)[number]["id"].- Одно описание формы даёт и список полей через
keyof, и значения черезRecord, и черновик черезPartial. - Тип состояния снимают с фабрики через
ReturnType, а у асинхронной функции — черезAwaited<ReturnType<…>>. - Вывод повторяет значение, а не проверяет его: опечатка в источнике становится частью типа, а на границе модуля тип по-прежнему пишут руками.
1/4Массив TABS объявлен без as const, а тип написан как (typeof TABS)[number]["id"]. Что получится?
2/4loadUser — асинхронная функция, возвращающая { id, name }. Что такое ReturnType<typeof loadUser>?
3/4Tab выведен из массива TABS, а рядом объявлен const icons: Record<Tab, string>. В массив добавили четвёртую вкладку. Что скажет компилятор?
4/4В массиве-источнике опечатка: идентификатор записан как "boadr". В коде вызывают openTab("board"). Что произойдёт?
Один список вместо трёх
Роли перечислены дважды — в массиве и в union, — и уже разъехались: «Гость» есть в данных, но не в типе. Сделайте массив единственным источником: Role должен получаться из него, а объект permissions — проверяться на полноту.
Массив фиксируем через as const, тип собираем индексным доступом. После этого Record<Role, number> сам потребует четвёртый ключ, и его останется дописать.
Заодно исчезла TS7053 в цикле: role.id теперь имеет тип Role, а не string, и индексация по нему законна. Именно поэтому «Гость» перестал быть undefined.
Корзина из загрузчика
Функция loadCart отдаёт корзину с валютой, а тип Cart написан руками и про валюту не знает — отсюда TS2339. Свяжите тип с функцией, не меняя саму функцию и не перечисляя поля заново.
Тип возврата у loadCart — обещание, поэтому одного ReturnType мало: нужна пара с Awaited.
Теперь любое новое поле в корзине становится доступным в render само, а удалённое — немедленно даёт ошибку в том месте, где им ещё пользуются.
Ошибки по полям
Тип ошибок формы переписан руками: поле agree потеряно, а в passwrod опечатка — и компилятор молчит, потому что сверять ему не с чем. Замените этот тип одним производным: ошибка может быть у любого поля SignupForm и только у него.
Список полей берём у самой формы через keyof, а «может быть, а может и не быть» добавляет Partial.
Проверить решение легко: допишите строку const typo: SignupErrors = { passwrod: "коротко" }; — в ответ придёт TS2561 с подсказкой Вы хотели записать "password"?. Раньше эта опечатка была частью типа, теперь она ошибка.