Перейти к содержанию

unknown на границе данных

Аннотация Course[] полезна для массива, написанного внутри проекта. Она помогает заметить несогласованность исходников. Но внешний JSON не проходил через эту проверку: его может изменить сервер, редактор или старое приложение. Сам факт получения синтаксически правильного JSON ничего не говорит о названиях полей и допустимых значениях.

На границе таких данных используем unknown: значение уже есть, но его свойства ещё не доказаны. Задача урока — создать настоящую исполняемую проверку четырёх учебных курсов и показать, как она превращает неизвестное значение в новую согласованную модель. Не будем добавлять HTTP: источник останется локальной строкой, чтобы не смешивать проверку формы и сетевую обработку.

Почему начать с unknown

unknown позволяет принять любое значение, но не разрешает произвольно обращаться к его полям. Перед арифметикой, вызовом метода или чтением обязательного свойства понадобится уточнение. Это полезная остановка на границе доверия: код прямо показывает, где внешнее значение ещё не стало внутренним контрактом.

JSON.parse может возвращать значение, которое не ограничивает дальнейшие операции достаточно строго. Поэтому мы явно записываем результат в переменную external: unknown. Это не проверка само по себе, а выбор безопасной отправной точки. В дальнейшем полагаемся на собственный парсер схемы, а не на утверждение as Course[].

В src/model.ts сохраните Topic, TopicFilter и Course из урока 4, а после них добавьте следующие полные функции:


function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function parseCourse(value: unknown): Course {
  if (!isRecord(value)) throw new Error('Ожидался объект курса');
  const { id, title, topic, lessons, url, description } = value;
  if (typeof id !== 'string' || id.trim() === '' ||
      typeof title !== 'string' || title.trim() === '' ||
      (topic !== 'frontend' && topic !== 'publishing') ||
      typeof lessons !== 'number' || !Number.isInteger(lessons) || lessons <= 0 ||
      typeof url !== 'string' || !/^\.\/courses\/[a-z-]+\.html$/.test(url) ||
      (description !== undefined && typeof description !== 'string')) {
    throw new Error('Некорректные поля курса');
  }
  return { id, title, topic, lessons, url,
    ...(description === undefined ? {} : { description }) };
}

export function parseCourses(value: unknown): Course[] {
  if (!Array.isArray(value)) throw new Error('Ожидался массив курсов');
  const ids = new Set<string>();
  return value.map((entry: unknown) => {
    const course = parseCourse(entry);
    if (ids.has(course.id)) throw new Error('Повторяющийся id курса');
    ids.add(course.id);
    return course;
  });
}

isRecord допускает ненулевой объект, исключая массив. Предикат value is Record<string, unknown> сообщает инструменту результат этой проверенной операции. Он не утверждает, что перед нами курс: все свойства остаются неизвестными. Название функции соответствует именно такой ограниченной гарантии.

Затем parseCourse читает поля и проверяет каждое правило. Идентификатор и название должны быть непустыми строками, тема — одним из двух значений, количество — положительным целым числом. Проверка URL ограничена путями вида ./courses/имя.html с латинскими строчными буквами и дефисами. Это договор нашего fixture, а не универсальная оценка безопасности всех веб-ссылок.

Новый объект после проверки

Последняя строка parseCourse создаёт новый объект из разрешённых полей. Мы не возвращаем исходный объект с незнакомыми дополнительными свойствами. Так внутреннее представление соответствует описанной форме, а внешний источник не может незаметно протащить посторонние параметры в дальнейшую сериализацию.

Необязательное описание добавляется только тогда, когда оно определено. Это согласовано с exactOptionalPropertyTypes: отсутствие поля не подменяется явным description: undefined. Если присутствует строковое описание, оно сохраняется без изменения. Пустое описание текущая схема разрешает; поле не заявлено обязательным редакционным текстом.

В этой реализации явное description: undefined во внешнем JavaScript-объекте нормализуется в отсутствие. JSON сам не выражает undefined, но наш парсер принимает unknown и потому может получить объект от другого вызывающего кода. Это осознанная политика для одного необязательного поля. Обязательное отсутствующее поле, напротив, является ошибкой.

parseCourses проверяет массив и уникальность id. Проверка каждой отдельной записи не обнаружила бы два курса с одинаковым идентификатором. Для общего ограничения нужен контекст коллекции, поэтому набор ids расположен вне callback. Пустой массив разрешён: он означает, что источник не содержит курсов, а не что схема сломана.

Успех и отказ на одних данных

Полный src/app.ts демонстрирует оба исхода:

import { courses } from './data.js';
import { parseCourses } from './model.js';

const source = JSON.stringify(courses);
const external: unknown = JSON.parse(source);
try {
  const accepted = parseCourses(external);
  console.log(`Принято курсов: ${accepted.length}`);
  parseCourses([{ id: 'broken', lessons: '20' }]);
} catch (error: unknown) {
  console.log(error instanceof Error ? error.message : 'Неизвестная ошибка');
}

Ожидаются строки «Принято курсов: 4» и «Некорректные поля курса». Первая часть сериализует исходный локальный массив и проверяет разобранное значение. Вторая сознательно передаёт неполную запись со строковым числом. Она не является пятой карточкой и не меняет исходные данные; это отдельный пример отказа.

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

В catch ошибка тоже начинается с unknown. JavaScript позволяет бросить не только экземпляр Error, но и строку или иной объект. Проверка instanceof Error даёт основание читать message; запасной текст предназначен для остальных случаев. Мы не выдаём точные номера диагностик компилятора или протокол выполнения: пример при подготовке не исполнялся.

Где легко создать ложную гарантию

Предикат пользовательской функции принимается инструментом как обещание автора. Если написать isCourse и вернуть просто true, инструмент поверит заявленному типу, хотя внешний объект не изменится. Поэтому такие функции требуют особенно внимательного чтения. В нашем примере небольшой isRecord доказывает только свою узкую часть, а остальные проверки явно остаются в парсере.

Проверить typeof lessons === 'number' недостаточно для правила количества. Дробь, отрицательное число и бесконечность не соответствуют целому положительному количеству. Number.isInteger и сравнение с нулём добавляют именно смысловое ограничение. Тип number этого диапазона не обещает и после проверки не превращается в особый встроенный тип положительных чисел.

Отказ одной записи и ответственность редактора

Если в середине внешнего массива встретится неверный курс, map не отдаст вызывающей стороне готовый частичный результат. Ошибка остановит операцию. Уже созданные во внутренней работе объекты не становятся опубликованным каталогом, поскольку функция ничего не записывает и не показывает до возврата. Поэтому отказ не следует описывать как успешную загрузку с потерянными карточками.

Для редакционного приложения может понадобиться точный номер неверной записи и имя поля. Это разумное дальнейшее улучшение, но оно не должно менять базовую границу: неправильные данные не объявляются правильными. Сейчас сообщение общее, а подробная диагностика потребовала бы собственного типа ошибки или параметров сообщения, которые затем сможет показать вызывающая сторона.

Уникальность id проверяется по точной строке. Значения javascript и JavaScript считаются разными, потому что парсер не нормализует регистр идентификатора. Аналогично название с пробелами по краям сохраняется, если оно не состоит только из пробелов. Проверка допустимости и нормализация являются разными решениями; не нужно обещать очистку, которой в коде нет.

Вход null откажет на уровне массива, массив [null] — на уровне объекта, а массив с двумя правильными одинаковыми id — на уровне коллекции. Эти три сценария помогают проследить положение правила, даже без запуска. Только после успешного прохождения всей цепочки дальнейшие функции получают Course[] и могут считать сумму без повторного перечисления проверок каждого свойства.

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

Сужение неизвестного значения и пользовательские предикаты описаны в Handbook. Собственная схема курса показывает главное практическое различие: типовая аннотация связывает исходники, а исполняемые условия защищают вход. В следующем уроке будем использовать уже проверенную модель в функциях отбора и сводки, не заставляя каждую из них заново разбирать JSON.

Оглавление курса.