Проект: план и модель данных
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Дальше — маленький проект: каталог материалов с подписями видов, сортировкой и состоянием загрузки. Кода в нём немного, и почти весь он будет написан в следующих трёх статьях; сейчас мы решаем то, что решают до кода, — как выглядит карточка и что бывает с экраном. Два объявления, из которых потом выводится всё остальное.
Каталог — это список карточек трёх видов, подписи к ним, сортировка и четыре положения экрана: ничего не запрашивали, грузим, показали, сломались. Логики здесь строк на полсотни, и соблазн начать сразу с неё велик. Мы начнём с типов — не из любви к церемониям, а потому что модель, написанная после кода, наследует его случайности: поле, которое понадобилось одной функции, навсегда остаётся необязательным.
| Каталог умеет | Кто это проверит |
|---|---|
| Хранить карточки трёх видов: книга, курс, видео | Компилятор: у книги не может быть длительности |
| Показывать подпись вида рядом с названием | Компилятор: появился вид — подпись обязана появиться |
| Сортировать список по общим полям карточки | Компилятор: по числу страниц нельзя — их нет у видео |
| Показывать загрузку, ошибку и пустой результат | Компилятор: «грузим» и «сломались» одновременно не соберутся |
| Получать данные одним ответом из сети | Никто: на границе это unknown — статья 2 главы |
Чего в проекте не будет: сервера, маршрутов, React и сборки — это части IV и V. Каждый пример здесь самодостаточен: запускается прямо на странице и ничего ниоткуда не импортирует.
Первое решение принимается до первой функции — как выглядит карточка. Вот как её описывают, когда торопятся.
Ни одной диагностики. Компилятор промолчал не по недосмотру: мы сами объявили kind строкой, а "bok" — строка. string в модели значит «сюда годится любая строка», и вся договорённость о видах материала переезжает в комментарий рядом с полем. Как сделать из перечисления тип, разбиралось в статье про литеральные типы; сейчас важнее другое — заметить, что тип ничего не пообещал.
Тип, который ничего не запрещает, ничего и не проверяет. Строка вместо перечисления видов — самая частая дыра в модели.
Вторая попытка обычно выглядит так: один тип на все виды, а поля, которых у вида нет, объявлены необязательными или «числом либо undefined».
Снова тишина от компилятора — и «страниц: undefined» в выводе. Тип разрешил собрать книгу с длительностью и без страниц, потому что он никак не связывает kind с остальными полями: каждое живёт само по себе. Такие сочетания называют невозможными состояниями — невозможными по смыслу, но представимыми в типе.
Лечится это сменой вопроса. Не «какие поля бывают у карточки», а «какие бывают карточки». Общее — в отдельный тип, различия — в свой тип на каждый вид, поле-метка kind — в каждый из них. Приём знаком по статье про размеченные объединения; здесь он становится проектным решением, от которого зависят остальные три статьи.
Три интерфейса вместо одного и ни одного необязательного поля. switch по метке сузил Item до конкретного варианта, поэтому item.pages в ветке book — обычное число, а не «число или undefined»; ветки для чужих полей просто нет.
А книга с длительностью теперь не собирается.
TS2353: Объектный литерал может использовать только известные свойства. "minutes" не существует в типе "Book". Посмотрите, как компилятор нашёл виноватого: по метке kind: "book" он выбрал из объединения ровно один вариант и сверял литерал уже с ним — поэтому в тексте Book, а не безымянное «одно из трёх».
Одно поле-метка стоит трёх необязательных: оно не описывает данные, а разделяет их на варианты.
Три интерфейса могли бы повторить id, title и year у себя — и разъехались бы при первой же правке. extends здесь не столько экономия строк, сколько имя для понятия «то, что есть у любой карточки»: дальше по нему удобно строить ключи сортировки и формы фильтров.
Пересечение через & дало бы тот же набор полей, но в диагностиках всплывало бы слагаемое, а не имя Book, — об этой разнице была статья «type или interface: чем они правда отличаются». В проектной модели читаемость сообщений важнее: их вы будете видеть чаще, чем сам тип.
Вокруг модели немедленно заводятся списки-спутники: подписи для интерфейса, ключи сортировки, наборы фильтров. Каждый такой список — вторая запись той же формы данных, и расходятся с моделью первыми именно они. Поэтому спутники не пишутся руками, а выводятся.
Item["kind"] — индексный доступ: список видов взят из самой модели, а не переписан рядом с ней. Record<ItemKind, string> требует по ключу на каждый вид: ни забыть, ни добавить лишний нельзя.
Ценность такой связи видна в момент правки. Уберём одну подпись — как будто вид добавили, а таблицу подписей не тронули.
TS2741: Свойство "video" отсутствует в типе "{ book: string; course: string; }" и является обязательным в типе "Record<"book" | "course" | "video", string>". Компилятор показывает на объявление таблицы и прямо называет недостающий ключ. Заодно видно, что псевдоним ItemKind в тексте раскрыт до трёх литералов: сообщения печатаются в терминах вычисленного типа, а не ваших имён.
Второй спутник — ключи, по которым каталог сортирует. Сортировать можно только по тому, что есть у каждой карточки, и такой список тоже незачем писать руками: keyof по объединению даёт пересечение ключей, а не их сумму.
Сортировка по названию отработала, а "pages" получил TS2345: Аргумент типа ""pages"" нельзя назначить параметру типа ""year" | "kind" | "id" | "title"" — компилятор перечислил все допустимые ключи. Запрет честный: у курса и видео страниц нет, и сортировка развалилась бы на первом же чужом элементе.
keyof по объединению — пересечение ключей, а не сумма: остаётся то, что есть у каждого варианта.
Данные описаны. Второе решение, которое принимается до кода, — что бывает с экраном. Положений четыре: каталог ещё не открывали, данные грузятся, список показан, запрос сломался. Первый порыв — сложить всё в три поля: флаг загрузки, текст ошибки и список.
Компилятор молчит, а экран врёт: сообщение «сеть недоступна» не увидит никто, потому что loading проверяется первым. Дело не в порядке условий — дело в том, что состояние «грузим, сломались и что-то нашли» вообще удалось собрать. Два признака дают четыре сочетания, осмысленных среди них три, а четвёртое — та самая чепуха. И наоборот: положения «каталог ещё не открывали» здесь нет вовсе — от «показали пустой список» оно неотличимо.
Те же четыре положения, описанные как размеченное объединение.
Каждый вариант несёт ровно свои данные: items есть только у ready, message — только у error. Достать сообщение об ошибке из состояния загрузки невозможно, его там физически нет. Ветка default с присваиванием в never — страховка из статьи про полный разбор вариантов: появится пятое положение, а ветка для него нет — компилятор укажет сюда.
И главное: невозможное перестало собираться.
Первая диагностика — знакомая TS2353 про лишнее свойство: по метке "loading" выбран вариант { status: "loading"; }, а message в нём не предусмотрен. Вторая — TS2322: Тип "{ status: "ready"; }" не может быть назначен для типа "CatalogState" с вложенной причиной про отсутствующее items. Состояние «список показан», у которого нет списка, в этой модели не выражается — а с флагами оно было значением по умолчанию: loading: false, error: null и пустой массив.
Соблазн добавить пятый вариант empty появляется сразу. Обычно он лишний: пустой результат — это ready с пустым items, данные пришли, их ноль. Отдельный вариант заводят, когда у него своя причина и свои данные: «фильтр ничего не нашёл» знает про строку запроса, «каталог пуст» не знает ни про что.
Правило простое: вариант оправдан, если хотя бы одно поле есть только у него или ветка разбора для него другая. Пока разница видна лишь в тексте на экране — это работа рендера, а не типа, и ей посвящена четвёртая статья главы.
Пока сети нет, каталог живёт на демо-данных прямо в коде. Их хочется сверить с моделью — и привычная аннотация здесь подводит.
TS2339: Свойство "pages" не существует в типе "Item" с вложенной причиной про Video. Аннотация не только проверила массив, но и назначила ему тип: каждый элемент теперь «одна из карточек», и до полей конкретного вида не добраться без сужения. Для функции, которая получает чужой список, это правильно; для литерала, написанного тут же, — потеря.
satisfies из главы про границу программы проверяет ровно то же самое, но вывод не трогает.
Демо-данные остались тем, что написано: первый элемент — книга со страницами, второй — видео с минутами. Проверка при этом полная — перепутайте поле, и ошибка появится в самом литерале, а не там, где данные потом читают.
С живыми данными так не выйдет: они приезжают из сети как unknown, и никакая аннотация их не осматривает — as Item[] тут просто ложь компилятору. Модель, которую мы написали, станет мишенью разбора: превратить unknown в Item[] — работа следующей статьи.
| Статья главы | Что появляется в каталоге |
|---|---|
| План и модель данных | Item, ItemKind, CatalogState — то, что мы только что написали |
| Разбор входных данных | Проверки на границе: из unknown в Item[] или в понятную ошибку |
| Свои утилиты на дженериках | Группировка, сортировка и поиск — одни на все виды карточек |
| Рендер и доводка настроек | Вывод по состоянию и строгость компилятора под проект |
Ни одна из трёх оставшихся статей модель переписывать не будет — они будут на неё опираться. Это и есть проверка плана: если через три статьи Item и CatalogState остались прежними, время на типы потрачено не зря.
- Модель пишется до кода: тип, выведенный из готовых функций, наследует их случайности — строки вместо перечислений и необязательные поля «на всякий случай».
- Разные виды карточек — это объединение вариантов с полем-меткой, а не один тип со всеми полями сразу; иначе книга с длительностью собирается молча.
- Спутники модели — подписи, ключи сортировки, фильтры — выводятся из неё через
Item["kind"]иkeyof, иначе расходятся с ней первыми. keyofпо объединению даёт пересечение ключей: остаются поля, которые есть у каждого варианта — ровно те, по которым безопасно сортировать.- Состояния экрана описываются размеченным объединением: «грузим» и «сломались» одновременно должны не собираться, а не «не встречаться на практике».
- Демо-данные проверяются через
satisfies, а данные из сети — разбором на границе: аннотация иasдля чужогоunknownне проверка, а обещание.
1/4Модель каталога — объединение Item = Book | Course | Video: общие поля id, title, year, kind и у каждого варианта своё поле про объём. Что даст keyof Item?
2/4Состояния описаны размеченным объединением, среди вариантов есть { status: "loading" }. Что скажет компилятор на const s: CatalogState = { status: "loading", message: "нет сети" }?
3/4В объединение Item добавили четвёртый вид, а таблицу подписей Record<ItemKind, string> не тронули. Куда покажет компилятор?
4/4Демо-массив с книгой и видео записан двумя способами: const DEMO: readonly Item[] = [...] и const DEMO = [...] satisfies readonly Item[]. Где сработает DEMO[0].pages?
Новый вид материала
В каталог добавили подкасты: интерфейс Podcast написан и включён в объединение. Компилятор выдал две диагностики. Сначала предскажите, куда он покажет, а потом запустите и почините — не трогая объявление Item.
Обе диагностики показывают на места, которые обязаны знать про все виды. Первая — TS2741 на объявлении KIND_LABEL: Record<ItemKind, string> требует ключ на каждый литерал из ItemKind, а podcast там появился. Вторая — TS2322 в ветке default: после трёх разобранных вариантов в item остался Podcast, и в never он не помещается.
Обратите внимание, чего не пришлось делать: искать по проекту места, где перечислены виды. Их назвал компилятор — потому что оба списка выведены из Item, а не написаны рядом с ним.
Состояние без невозможных сочетаний
Экран каталога описан тремя флагами, и в примере собрано состояние, которого не бывает: грузим, сломались и что-то нашли. Перепишите Screen размеченным объединением так, чтобы это состояние перестало собираться, а view разбирал варианты через switch с веткой never на конце.
Каждое положение экрана становится отдельным вариантом и несёт только свои данные: items — у ready, message — у error. Поле-метка status общее, по нему и идёт разбор.
Проверьте себя: попробуйте собрать прежнее состояние — { status: "loading", message: "сеть недоступна" }. Вы получите TS2353 про лишнее свойство, и это ровно тот запрет, ради которого затевалась переделка.
Ключи сортировки из модели
Список ключей сортировки написан руками и уже разошёлся с моделью — поля author в карточке нет, и компилятор дважды выдаёт TS7053. Замените SortKey так, чтобы список ключей брался из самой модели и разойтись с ней не мог.
Ключи берутся из модели одним словом: keyof по объединению оставляет пересечение ключей — поля, которые есть у книги и у видео одновременно.
Диагностики TS7053 исчезают: индексировать Item таким ключом можно всегда. Заодно появляется защита с другой стороны — вызов sortBy(items, "pages") теперь не компилируется: TS2345 перечислит допустимые ключи. Если сортировать по id и kind вы не хотите, сузьте список явно: type SortKey = keyof Base — но список всё равно останется срезом модели, а не второй её копией.