Объединения типов
Поле темы в каталоге не должно принимать произвольный текст. Для текущих четырёх курсов определены две темы: frontend и publishing. Фильтр дополнительно поддерживает all, но all не является темой отдельного курса. Эти два набора связаны и одновременно различаются.
Введём объединение типов: значение может соответствовать одному из перечисленных вариантов. Мы будем применять уже знакомый JavaScript-фильтр, а новый результат состоит в точном описании его параметра. Такая модель помогает не перепутать тему записи и режим отображения всех тем, не добавляя специальной проверки в браузер исключительно ради локальных строковых констант.
Тема записи и тема отбора
Замените определение src/model.ts следующим полным содержимым:
export type Topic = 'frontend' | 'publishing';
export type TopicFilter = Topic | 'all';
export interface Course {
id: string;
title: string;
topic: Topic;
lessons: number;
url: string;
description?: string;
}
Вертикальная черта читается как «или». Topic разрешает ровно два строковых значения. TopicFilter включает весь набор Topic и один дополнительный режим. Если позже появится тема backend, достаточно осмысленно добавить её в Topic; режим всех тем не требуется заново описывать в каждой функции.
Слово type вводит имя типа. Оно не создаёт массив разрешённых значений во время выполнения. Поэтому нельзя перебрать Topic в JavaScript-цикле или показать его как объект. Для списка вариантов интерфейса понадобится реальное значение, например массив строк. Различие между объявлением и исполняемым значением будет проходить через всю серию.
В src/data.ts остаётся прежний импорт Course и аннотация Course[]. Значения четырёх записей не меняются. Но попытка написать topic: 'all' внутри записи теперь противоречит форме курса. В интерфейсе Course поле имеет тип Topic, а не TopicFilter. Это и есть полезная граница: данные описывают принадлежность, параметры отбора описывают действие пользователя.
Функция с несколькими допустимыми вариантами
Полный src/app.ts:
import { courses } from './data.js';
import type { Course, TopicFilter } from './model.js';
function byTopic(courses: readonly Course[], topic: TopicFilter): Course[] {
return courses.filter(course => topic === 'all' || course.topic === topic);
}
for (const topic of ['all', 'frontend', 'publishing'] as const) {
console.log(`${topic}: ${byTopic(courses, topic).length}`);
}
Ожидаемые строки:
all: 4
frontend: 3
publishing: 1
Функция byTopic всегда возвращает массив курсов. Объединение находится у параметра topic, а не у результата. Для all условие принимает любую запись, для конкретной темы сравнивает поле. Механизм отбора выполняется обычным JavaScript; тип лишь ограничивает корректные вызовы из известного исходного текста.
В цикле массив вариантов помечен as const. Пока достаточно понимать, что мы просим сохранить точные неизменяемые литералы элементов, чтобы каждый элемент оставался допустимым TopicFilter, а не становился произвольной строкой. Подробно этот приём разберём в четырнадцатом уроке. Вариант без него можно написать с явной аннотацией const filters: TopicFilter[] = [...].
Если вызвать byTopic(courses, 'frontend'), параметр понятен. Если вызвать byTopic(courses, 'frontned'), инструмент должен указать, что такого варианта нет в договоре. Нам не нужен настоящий запрос к серверу для обнаружения этой опечатки. Однако строка, введённая посетителем в адресной строке, остаётся внешней строкой и требует проверки перед передачей в функцию.
Доступны общие возможности
Объединение описывает несколько возможностей, поэтому без дополнительной информации разрешены действия, корректные для всех его вариантов. В нашем TopicFilter каждый вариант является строкой: можно сравнивать значение с литералом и читать length. Но объединение Course | undefined не даст безусловно прочитать title, поскольку один вариант вообще не содержит объекта.
Такое ограничение полезно увидеть заранее. Оно не означает, что значение «плохое» или его нужно любой ценой привести к Course. Оно означает, что программа должна определить, какой вариант получила. В следующем уроке функция поиска создаст именно такой результат, а условие отделит найденный курс от его отсутствия.
Само объединение не выбирает вариант и не преобразует вход. Если значение в памяти содержит publishing, оно остаётся этой строкой. Если неизвестный внешнему коду источник прислал frontned, аннотация параметра не исправит букву. Программа JavaScript продолжит работать с фактической строкой, если обойти границу проверки или вообще не запускать инструмент типов.
Проверка строки на входе
Рассмотрим самостоятельный фрагмент для чтения. Его можно добавить после функции byTopic; он явно переводит свободный текст в понятный режим:
function parseFilter(value: string): TopicFilter {
if (value === 'frontend' || value === 'publishing' || value === 'all') return value;
throw new Error('Неизвестный режим отбора');
}
const selected = byTopic(courses, parseFilter('frontend'));
console.log(selected.length);
Здесь происходит настоящая проверка значения. Возврат разрешён после сравнения с тремя литералами. Ожидаемое количество — три. Для неизвестной строки функция не возвращает фиктивную тему, а явно сообщает ошибку. Это одна возможная политика; приложение могло бы выбрать all, но тогда в тексте нужно честно назвать замену неподдерживаемого режима.
Почему не написать value as TopicFilter? Такая запись сообщила бы инструменту, что автор уже установил корректность строки. Она не сделала бы ни одного сравнения. В нашем примере как раз требуется сравнение, поэтому утверждение типа скрывало бы отсутствующую работу. Приведение бывает полезно на доказанной границе, но не должно подменять доказательство.
Правило согласования объединений объясняется в Everyday Types. Наш собственный договор состоит из двух разных наборов, четырёх исходных записей и результата 4/3/1. Эти числа позволяют проверить, что типовая модель не изменила смысл фильтра: новое ограничение помогает автору, а поведение каталога остаётся прежним.
Добавление новой темы требует двух решений
Предположим, каталог должен поддержать backend. Первое решение касается модели: входит ли новая тема в допустимый Topic. Второе касается данных и интерфейса: есть ли записи этой темы и как посетитель сможет выбрать её. Добавление строкового литерала само по себе не создаст курс и не добавит пункт в настоящий HTML-select.
Условие byTopic уже сравнивает тему записи с параметром, поэтому его общий механизм может сохранить форму. Но демонстрационный массив трёх режимов тоже потребуется обновить, если мы хотим показать новую строку сводки. Для отсутствующих записей ожидается нулевое количество, а не ошибка типа. Это помогает отделить допустимость режима от наличия данных в нём.
В текущей партии мы не выполняем такое расширение и не меняем четыре исходные записи. Пример добавления темы нужен для чтения отношений: один тип может участвовать сразу в модели, параметрах и списке режимов, но эти связи не исполняют редакционные действия автоматически.
При чтении внешнего фильтра следует решить, чувствителен ли он к регистру. Значения frontend и Frontend являются разными строками. Наш parseFilter допускает только первое. Если продукт требует нечувствительность, нужна отдельная нормализация до сравнения и объяснение политики. Утверждение as TopicFilter не нормализует регистр, как не исправляет опечатку. Конечный договор полезен именно тогда, когда его границы видны и в типах, и в настоящей проверке входа.
При дальнейшей работе старайтесь давать имена типам по назначению. Название вроде AnyTopic было бы неоднозначным: оно может означать произвольную строку, все существующие темы или режим всех тем. Topic и TopicFilter показывают различие непосредственно. Тогда функция карточки не получит лишний режим, а функция формы сможет работать с ним осознанно.
Исходники этого урока подготовлены без запуска компилятора. Ожидаемые результаты следуют из фиксированных данных. В следующем уроке займёмся другим объединением — найденным курсом или отсутствием курса — и увидим, как условие меняет доступные операции внутри ветки.