Ограничения параметров типа
Общая функция first<T> не требовала никаких свойств элемента. Для построения индекса ситуация другая: каждой записи нужен строковый идентификатор. В то же время не обязательно ограничивать индекс только курсами. Разные записи проекта могут иметь id и собственные дополнительные поля.
Ограничение параметра типа описывает минимальную форму, которая необходима реализации. Функция получает доступ к id, но сохраняет точный тип всей записи в результате. В учебном каталоге построим Map, чтобы поиск возвращал исходный Course, включая название и число уроков, а не обрезанную форму с одним идентификатором.
Минимальное требование и полный результат
Полный src/app.ts снимка lesson-10:
import { courses } from './data.js';
function indexById<T extends { readonly id: string }>(items: readonly T[]): Map<string, T> {
const index = new Map<string, T>();
for (const item of items) {
if (index.has(item.id)) throw new Error('Повторяющийся id');
index.set(item.id, item);
}
return index;
}
const index = indexById(courses);
console.log(index.get('javascript')?.title ?? 'Курс не найден');
console.log(index.get('missing')?.title ?? 'Курс не найден');
Ожидаются строки «Современный JavaScript» и «Курс не найден». Индекс содержит четыре исходные записи. Для неизвестного ключа Map.get возвращает отсутствие, которое мы обрабатываем. Ограничение типа не обещает существование каждого запрошенного идентификатора.
В записи T extends { readonly id: string } слово extends задаёт условие совместимости. Каждый допустимый T должен иметь строковое поле id. Это не объявление класса и не наследование объектов при выполнении. Курс подходит по своей структуре; дополнительное поле title остаётся частью конкретного T.
Результат Map<string, T> хранит ту же форму элемента, которая передана функции. Если бы мы объявили вход и результат только как { id: string }, реализация могла бы построить индекс, но вызывающая сторона потеряла бы сведения об остальных полях. Ограничение отличается именно сохранением этого знания.
Readonly у id показывает, что реализации достаточно чтения. Мы не собираемся переназначать идентификаторы в процессе индексации. При этом Map сам изменяемый: функция добавляет записи, а вызывающая сторона после возврата имеет обычный Map. Если нужен контракт только чтения результата, его можно отдельно описать как ReadonlyMap<string, T>; это другой уровень ограничения.
Повторяющиеся ключи — правило выполнения
Две записи с одинаковым строковым id обе соответствуют статическому ограничению. Инструмент типов не знает общего набора ключей обычного массива и не проверяет их уникальность. Если безусловно вызывать set, одна запись заменит другую. Поэтому реализация явно проверяет наличие ключа и бросает ошибку при повторе.
В шестом уроке парсер курсов уже проверял уникальность. Здесь похожая защита уместна потому, что новая функция является общей и может получить массив другого происхождения. Её собственный результат должен соответствовать объявленному поведению: каждая входная запись индексируется либо операция явно отказывает, а не молча сокращает каталог.
Сделайте отдельный мысленный вызов с двумя объектами { id: 'same', title: 'Первый' } и { id: 'same', title: 'Второй' }. Типовое ограничение выполнено, но исполняемая проверка обнаружит повтор. Это показательный случай: соответствие форме и соответствие правилу коллекции не являются одним действием.
Функция не запрещает пустую строку идентификатора. Для курсов это уже проверяет парсер, а общий индекс обещает только строковую форму и отсутствие повторов. Если желаете запретить пустые ключи и для остальных записей, добавьте реальное условие и объясните новую политику. Не приписывайте ограничению string смысл непустого или существующего ключа.
Разные записи сохраняют свои свойства
Самостоятельный фрагмент после функции индексирования:
const authors = [{ id: 'editor', name: 'Редакция', active: true }];
const authorIndex = indexById(authors);
const author = authorIndex.get('editor');
if (author !== undefined) console.log(author.name);
Ожидается «Редакция». Функция знает только идентификатор, но тип результата сохраняет name и active, поскольку они принадлежат конкретному элементу входного массива. Нам не потребовался отдельный индексатор для авторов. Фрагмент иллюстрирует второй вызов, не добавляя автора в массив курсов.
Если передать массив чисел, условие не выполнено: числа не предоставляют требуемое поле. Если передать { id: 10 }, поле есть, но его тип не строковый. В обоих случаях корректное решение зависит от данных. Можно выбрать другой общий контракт для числовых ключей, но механическое as не превратит число в строку.
Обратите внимание на использование T в нескольких местах подписи. Параметр типа связывает входные элементы и значения результата. Если бы возвращаемое значение никак не зависело от частного типа, обычного параметра с минимальной формой часто было бы достаточно. Здесь зависимость нужна, потому что после поиска читатель работает с полями конкретной записи.
Ограничение не является конструктором
Создать произвольный новый T из одного id внутри такой функции нельзя. T может иметь обязательные поля, о которых реализация ничего не знает. Возвращать { id } as T означало бы выдумать наличие этих полей. Наш индекс хранит уже полученные записи и потому не делает такого обещания.
Это важное правило при проектировании общих фабрик. Минимальное ограничение даёт право читать минимальные свойства, но не право изготовить любой более богатый вариант. Если нужно создавать новые элементы, требуется переданная фабрика или отдельный контракт данных создания. Позже мы рассмотрим форму создания курса, где нужные свойства известны.
Индекс и массив решают разные задачи
Исходный массив сохраняет порядок четырёх курсов, а Map обеспечивает чтение по ключу. Создание индекса не обязано удалять или заменять массив. Для интерфейса часто нужны обе формы: упорядоченный обход карточек и быстрый выбор одной записи. Согласованность их жизненного цикла важнее, чем само наличие обобщённой подписи.
В этом уроке данные фиксированы, поэтому индекс строится один раз для одного снимка. Если позже каталог заменится новым массивом, прежний Map не обновится автоматически. Нужно перестроить его или явно поддерживать изменения. Тип Map<string, Course> не помечает результат как устаревший после изменения источника.
При чтении отсутствующего ключа мы показываем сообщение. Можно было бы сделать функцию обязательного поиска, которая бросает ошибку при отсутствии. Но тогда название и возвращаемый договор должны отражать эту политику. Само ограничение id: string не даёт основания считать любой внешний ключ существующим.
Для общего индекса разумно также определить, что делать при повторе. Наш выбор — отказ; другая операция могла бы хранить массив записей для каждого id или намеренно оставлять последнюю. Эти решения имеют разные результаты, хотя их входная форма совпадает. Поэтому правило повторов должно оставаться в реализации и пояснении. Прочитайте цикл в порядке входного массива: первый id сохраняется, второй такой же останавливает операцию, а вызывающая сторона не получает молча сокращённую коллекцию.
Копии записей здесь также не создаются. Map хранит исходные ссылки. Изменение курса через другой alias может стать видно при чтении индекса. Особую опасность представляет изменение id после индексирования: ключ и поле могут разойтись. Readonly-договор уменьшает случайные записи из обычного кода, но физическое владение объектами остаётся архитектурной задачей.
Ограничения обобщённых типов описаны в Handbook. Наш результат — индекс четырёх курсов с сохранённым типом каждого значения и явным отказом при повторе ключа. Пример не исполнялся при подготовке. В следующем уроке выразим ещё более точную связь: имя выбранного поля будет определять тип возвращаемого значения.