Выводить, а не дублировать

вывод типов≈ 11 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Список вкладок лежит в массиве, а union их идентификаторов написан руками — рано или поздно эти два описания расходятся, и компилятор не скажет об этом ни слова. Приёмы всей главы — as const, typeof, keyof, индексный доступ и утилиты — существуют ради одного правила: у сущности одно описание, всё остальное выводится из него.

Разделы темы:·

Почти любое приложение устроено одинаково: где-то лежат данные — список вкладок, ролей, статусов, — а где-то тип, перечисляющий их же. Пока список короткий, дублирование выглядит безобидной формальностью.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика одна: Аргумент типа ""map"" нельзя назначить параметру типа "Tab", это TS2345. Странность в том, что вызов правильный — вкладка map в данных есть, у неё есть заголовок, и после запуска в консоли появилась «Карта». Ошибся не вызов, а тип: он описывает мир, которого уже нет.

Вторая половина беды выглядит противоположно и обходится дороже. Если описания разъехались в другую сторону, компилятор промолчит вовсе.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни одной диагностики. Тип обещает вкладку chart, данные о ней не знают, titleOf честно отдаёт заглушку. Сборка зелёная, типы проверены — а в интерфейсе пустая вкладка.

Дело не в лени: переписать три строки недолго. Дело в том, что для компилятора Tab и TABS — два независимых объявления, между которыми нет никакой связи, и сверять их он не станет. Значит, сверять надо не глазами, а сделать одно из другого.

Копии описаний расходятся молча. Компилятор сторожит только те связи, о которых ему сказали.

Массив с данными старше типа: он нужен для рендера в любом случае, а тип придуман, чтобы его описать. Значит, источником назначаем массив, а тип получаем как производное — тремя знакомыми по этой главе шагами.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Выражение (typeof TABS)[number]["id"] читается слева направо. typeof TABS — тип значения, здесь это кортеж из трёх объектов с литеральными полями (за литералы отвечает as const). [number] — индексный доступ по числовому ключу, то есть «любой элемент», объединение трёх объектов. ["id"] берёт у каждого поле id, и получается union трёх строк.

Компилятор ответил Аргумент типа ""chart"" нельзя назначить параметру типа ""map" | "list" | "board"" и заодно показал содержимое union — ровно те три идентификатора, что лежат в массиве. Без as const фокус не сработал бы: у обычного массива поле id выводится как string, и Tab оказался бы просто string — проверкой, которая ничего не проверяет.

Настоящая польза видна не в момент написания, а в момент правки. Добавили в массив ещё одну вкладку — тип пересчитался сам, и вместе с ним изменились все места, где он используется.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

TS2741: Свойство "map" отсутствует в типе "{ list: string; board: string; }" и является обязательным в типе "Record<"map" | "list" | "board", string>". Компилятор указал на объект, который не успел за источником. Это и есть та работа, ради которой всё затевалось: правка в одном месте превращается в список дел в остальных.

Хороший производный тип превращает «не забыть обновить» в ошибку сборки.

Форма — самый частый случай, где описаний заводится сразу три: список полей для рендера, тип значений, тип ошибок. Хватает первого.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

От одного массива пошли три типа: Field — имена полей, FormValues — по строке на каждое, FormErrors — сообщение, может быть, у какого-то. Внутри цикла field имеет тип Field, а не string, поэтому values[field] проходит без TS7053 из статьи про индексные сигнатуры: компилятор знает, что такой ключ есть наверняка. А выдуманное поле ловится сразу — Свойство "adress" не существует в типе "FormValues", TS2339.

Иногда источником удобнее сделать не список имён, а объект-образец: пустую модель, с которой начинается форма, или набор значений по умолчанию. Тогда к typeof добавляется keyof, и одно значение раздаёт типы всему модулю.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Опечатку в патче поймал TS2353: Объектный литерал может использовать только известные свойства. "nmae" не существует в типе "Partial<{ id: number; name: string; email: string; active: boolean; }>". А запуск показал, чем это обходится без типов: в квадратных скобках пусто. Ключ nmae спокойно лёг в объект рядом с name, имя пользователя осталось прежним, и никто не заметил.

Обратите внимание на порядок: сначала появилось значение, которое всё равно нужно программе, и только потом типы. Придумывать значение ради вывода типов не надо — если источником просится тип, пусть источником и будет тип.

Не всякая правда лежит в константе. Часто настоящее описание — это функция: фабрика начального состояния, загрузчик данных, конструктор объекта. Тогда тип снимают с её результата.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

ReturnType<typeof createState> работает в два приёма: typeof превращает функцию-значение в тип функции, ReturnType достаёт из него тип возврата. Дописали в фабрику поле — оно тут же появилось у всех, кто работает с State, и первым сломалось восстановление: Свойство "open" отсутствует в типе "{ items: string[]; count: number; }". Ровно то место, куда и нужно было заглянуть.

С асинхронной функцией в цепочке появляется лишнее звено — обещание. Его видно, стоит попробовать использовать тип возврата напрямую.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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: он отдаёт кортеж типов аргументов. Пригождается там, где вызов откладывают — очередь заданий, повтор после ошибки, обёртка с логированием.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Кортеж из статьи про массивы здесь работает по назначению: длина известна, порядок типов известен, и sendMessage(...args) раскладывается по параметрам без единого приведения. Поменяется сигнатура функции — поменяется и содержимое очереди.

У приёма есть цена, и первый счёт приходит в тот момент, когда вы ошибаетесь в самом источнике. Выведенный тип не проверяет значение — он его повторяет.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Компилятор отверг правильное слово: Аргумент типа ""board"" нельзя назначить параметру типа ""list" | "boadr"". Опечатка попала в данные, стала частью типа и теперь защищена системой типов. Читать такую диагностику надо не как «ошибка в вызове», а как отчёт о содержимом источника — привычка из статьи про чтение диагностики: смотреть не только на подчёркнутую строку.

Вторая цена — расстояние. Поправили одну букву в константе, получили десяток ошибок в файлах, которые сегодня не открывали. Сделка выгодная (без вывода этих ошибок не было бы вовсе, был бы тихий сбой), но список надо читать снизу вверх: чинить не каждую строчку по очереди, а источник или самое первое несоответствие.

Третья граница — публичный контракт. Тип, выведенный из реализации, меняется вместе с реализацией: кто-то переименовал поле во внутреннем объекте, и у всех снаружи поехали типы. Пока источник и его потребители лежат в одном модуле, это польза. Как только тип уходит за границу модуля или пакета, его пишут руками, а реализацию проверяют на соответствие ему.

Внутри модуля источник — значение. На границе модуля источник — тип, а значение под него подгоняется.

Когда первичен тип, значение к нему прижимают аннотацией — как icons из второго раздела. Проверка полноты работает, но за неё берут плату: тип переменной становится тем, что написано в аннотации, а не тем, что лежит в объекте.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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 — проверяться на полноту.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Массив фиксируем через as const, тип собираем индексным доступом. После этого Record<Role, number> сам потребует четвёртый ключ, и его останется дописать.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Заодно исчезла TS7053 в цикле: role.id теперь имеет тип Role, а не string, и индексация по нему законна. Именно поэтому «Гость» перестал быть undefined.

Корзина из загрузчика

Функция loadCart отдаёт корзину с валютой, а тип Cart написан руками и про валюту не знает — отсюда TS2339. Свяжите тип с функцией, не меняя саму функцию и не перечисляя поля заново.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Тип возврата у loadCart — обещание, поэтому одного ReturnType мало: нужна пара с Awaited.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь любое новое поле в корзине становится доступным в render само, а удалённое — немедленно даёт ошибку в том месте, где им ещё пользуются.

Ошибки по полям

Тип ошибок формы переписан руками: поле agree потеряно, а в passwrod опечатка — и компилятор молчит, потому что сверять ему не с чем. Замените этот тип одним производным: ошибка может быть у любого поля SignupForm и только у него.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Список полей берём у самой формы через keyof, а «может быть, а может и не быть» добавляет Partial.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Проверить решение легко: допишите строку const typo: SignupErrors = { passwrod: "коротко" }; — в ответ придёт TS2561 с подсказкой Вы хотели записать "password"?. Раньше эта опечатка была частью типа, теперь она ошибка.