Массивы, кортежи и readonly
Одна функция показывает курсы в исходном порядке, другая сортирует их по числу уроков. Если сортировка изменит общий массив, последующая операция увидит уже другие данные. В JavaScript эта проблема решается управлением копиями и мутациями. TypeScript позволяет дополнительно выразить, что получатель коллекции должен только читать её.
Введём readonly для массива и полей курса, а результат сводки представим кортежем. У этих приёмов разные задачи: массив описывает произвольное число однотипных элементов, кортеж — определённые позиции, readonly — ограничение записи через конкретный контракт. Никакой из них сам по себе не замораживает значения при выполнении.
Три отдельных уровня
Замените начальные определения Topic, TopicFilter и Course в src/model.ts следующим фрагментом. Функции парсера и отбора ниже сохраняются:
export type Topic = 'frontend' | 'publishing';
export type TopicFilter = Topic | 'all';
export interface Course {
readonly id: string;
readonly title: string;
readonly topic: Topic;
readonly lessons: number;
readonly url: string;
readonly description?: string;
}
В src/data.ts объявление теперь имеет вид export const courses: readonly Course[] = ...; весь прежний массив и необязательное описание первой записи сохраняются. Полный снимок lesson-08 находится в архиве серии. Нельзя вставлять многоточие из этого абзаца как исходный код: это обозначение неизменяемого списка записей.
const у переменной предотвращает замену ссылки. readonly Course[] запрещает менять структуру массива через эту ссылку: например, использовать push или сортировку на месте. readonly у полей курса запрещает присвоить новое название через значение типа Course. Все три ограничения нужно различать, иначе обещание неизменности окажется шире реального договора.
Парсер по-прежнему возвращает новый Course[]. Этот изменяемый массив допустим там, где требуется только чтение. Обратное не является таким же обещанием: функция, требующая изменяемый массив, может попытаться его перестроить. Поэтому параметры selectCourses и countLessons уже были описаны как массивы для чтения.
Копия для сортировки
Полный src/app.ts:
import { courses } from './data.js';
import { countLessons } from './model.js';
import type { Course } from './model.js';
type Summary = readonly [count: number, lessons: number];
function summarize(items: readonly Course[]): Summary {
return [items.length, countLessons(items)];
}
const sorted = [...courses].sort((left, right) => left.lessons - right.lessons);
const [count, lessons] = summarize(sorted);
console.log(`Курсов: ${count}. Уроков: ${lessons}.`);
console.log(`Первый в данных: ${courses[0]?.id}`);
console.log(`Первый после сортировки: ${sorted[0]?.id}`);
Ожидается сводка «Курсов: 4. Уроков: 60», затем исходный первый id javascript и первый id сортированного представления performance. У двух курсов по 12 уроков; стабильная сортировка сохраняет их относительный порядок исходного массива, поэтому performance остаётся перед markdown.
Перед sort создаётся новый массив через spread. Это реальное действие JavaScript, которое отделяет структуру представления от исходной структуры. Сам readonly не создавал бы копию. Если бы инструмент проверки был обойдён, обычный массив всё ещё можно было бы изменить при выполнении.
Копия поверхностная. Объекты элементов остаются теми же объектами, а не независимыми глубокими копиями. В нашем статическом договоре их поля тоже readonly, но другой alias с изменяемым типом потенциально может менять тот же объект. Для задач, где нужна физическая неизменность, требуется отдельная стратегия владения и замораживания; этот урок её не реализует.
Зачем тогда readonly полезен? Он сообщает функции, какие действия допустимы, и помогает заметить случайную мутацию при обычной работе в проекте. Программа меньше зависит от порядка вызовов. Типовое ограничение не становится охраной памяти, но служит ясным договором между авторами модулей.
Кортеж сводки
Summary описывает две позиции: количество курсов и число уроков. Обе позиции числовые, но имеют разный смысл. Имена count и lessons в объявлении помогают редактору показывать назначение элементов. Они не создают объектных ключей во время выполнения: результат по-прежнему массив с двумя значениями.
Функция summarize возвращает [items.length, countLessons(items)]. Порядок значений является частью контракта. Деструктуризация const [count, lessons] читает этот порядок. Если случайно поменять числа местами, типы останутся числовыми и не заметят смысловую ошибку. Именованные позиции облегчают чтение, но не заменяют анализ формулы.
При необходимости можно выбрать объект { count, lessons } вместо кортежа. Это особенно полезно, когда значений много или их трудно помнить по позиции. В нашем примере кортеж небольшого размера показывает сам механизм фиксированной формы. Не нужно превращать каждую модель данных в массив только потому, что такая запись короче.
Рассмотрим неправильный фрагмент для чтения:
// Не добавляйте в рабочий файл: длина не соответствует Summary.
const broken: Summary = [4];
Заявлены две обязательные позиции, но значение содержит одну. Инструмент должен обнаружить несовпадение формы. В то же время обычный number[] мог бы содержать и одну, и три позиции: он выражает тип элемента, а не точную длину. На границе внешнего JSON даже кортежный тип не проверит длину без исполняемого условия.
Индекс может отсутствовать
У массива курсов число элементов не записано в типе как постоянная четвёрка. Поэтому обращение courses[0] допускает отсутствие элемента. Настройка noUncheckedIndexedAccess помогает не забыть эту возможность. В выводе мы используем ?.id, а для серьёзной операции следовало бы явно обработать пустой набор.
У кортежа с двумя обязательными позициями известные индексы имеют более точный контракт. Но динамический индекс всё равно требует внимания: произвольное число не гарантирует попадание в нужную позицию. Разница особенно заметна, когда позициям соответствуют разные типы, например строковая подпись и числовое значение.
Readonly не меняет владельца данных
Представьте, что другой модуль получил тот же массив под изменяемым типом и добавил запись. Получатель с readonly увидит новое содержимое при следующем чтении, хотя сам ничего не записывал. Это не нарушение поведения JavaScript: обе ссылки могут указывать на один объект. Ограничение относится к операциям через конкретный статический договор.
Поэтому readonly особенно полезен вместе с ясным владением. Модуль данных может хранить исходный набор и отдавать функции только чтение; функция представления делает свою копию структуры для сортировки. Если нужно зафиксировать набор на момент вызова, такая копия является реальным действием и должна быть создана явно.
Добавление нового объекта в копию не добавит его в исходный массив. Однако изменение вложенного объекта через допустимый изменяемый alias затронет обе структуры. Для нашего курса поля readonly сокращают случайные записи, но не следует называть spread глубокой защитой. Физическое замораживание тоже бывает поверхностным, если не обрабатывать вложенные значения отдельно.
У кортежа другой риск: два числовых результата легко перепутать местами, сохранив формальную совместимость. Если сводку начинают использовать многие модули, объект с именованными свойствами может оказаться яснее. Наш выбор кортежа показывает фиксированные позиции, а не утверждает, что это лучший формат любой статистики. В каждом случае форма должна помогать читателю увидеть назначение данных; короткая запись полезна лишь пока не заставляет постоянно помнить скрытый порядок значений.
Readonly-кортеж предотвращает перестановку или запись через эту ссылку, но не делает вложенные объекты глубоко неизменными. Если позиция содержит объект, её тип нужно рассматривать отдельно. Наш кортеж содержит только числа, поэтому такого вложенного уровня здесь нет.
Массивы для чтения и кортежи описаны в Object Types. В нашей модели ожидаемая сумма остаётся 60, а сортированное представление не изменяет исходный первый курс. Пример подготовлен без запуска. В следующем уроке создадим операцию чтения первого элемента, которая сохранит точный тип результата и для курса, и для строки, не возвращаясь к произвольному any.