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

Типизированная модель формы

Элемент input с type="number" остаётся полем браузерной формы. При чтении FormData его значение не превращается автоматически в число. Кроме того, поле может отсутствовать, иметь дублированное имя или содержать файл. Чтобы получить удобную внутреннюю модель отбора, нужно явно пройти границу формы.

Создадим Filter с темой и минимальным числом уроков. Значение all допустимо для отбора, но не для темы отдельного Course. Минимум ноль означает отсутствие нижнего ограничения. Четыре исходных курса сохраняются; полные файлы lesson-19 находятся в архиве продолжения. Программа не исполнялась при подготовке.

Форма передаёт текст

Полный index.html вводит select темы и числовое поле:

<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Каталог TypeScript</title><script type="module" src="./dist/app.js"></script></head>
<body><main><h1>Учебный каталог</h1>
<form id="catalog-controls"><fieldset><legend>Отбор курсов</legend>
<label>Тема <select name="topic"><option value="all">Все темы</option><option value="frontend">Фронтенд</option><option value="publishing">Публикация</option></select></label>
<label>Не меньше уроков <input type="number" name="minLessons" value="0" min="0" step="1" required></label>
<button type="submit">Применить</button></fieldset></form>
<p id="catalog-status" role="status" aria-live="polite"></p><ul id="catalog-list"></ul>
</main></body></html>

Атрибуты min, step и required помогают нативному интерфейсу. Но они не являются универсальным сертификатом входных данных для любой функции. Форму можно изменить программно, вызвать чтение отдельно или получить значение другим путём. Поэтому внутренняя модель всё равно создаётся после своих явных условий.

Мы используем событие submit, а не клик конкретной кнопки. Это сохраняет обычную работу Enter и позволяет форме иметь единый путь применения. После preventDefault приложение выполняет локальный отбор, не отправляя поля на сервер. Серверного сохранения в этом уроке нет.

Один вход — одна доменная форма

Полный src/form.ts:

import type { Course, TopicFilter } from './model.js';

export interface Filter {
  readonly topic: TopicFilter;
  readonly minLessons: number;
}

function singleText(data: FormData, name: string): string {
  const values = data.getAll(name);
  const value = values[0];
  if (values.length !== 1 || typeof value !== 'string') throw new Error(`Некорректное поле ${name}`);
  return value;
}

export function readFilter(form: HTMLFormElement): Filter {
  const data = new FormData(form);
  const topic = singleText(data, 'topic');
  const raw = singleText(data, 'minLessons').trim();
  if (topic !== 'all' && topic !== 'frontend' && topic !== 'publishing') throw new Error('Неизвестная тема');
  if (!/^(?:0|[1-9]\d*)$/.test(raw)) throw new Error('Нужно целое неотрицательное количество');
  const minLessons = Number(raw);
  if (!Number.isSafeInteger(minLessons)) throw new Error('Количество слишком велико');
  return { topic, minLessons };
}

export function applyFilter(courses: readonly Course[], filter: Filter): Course[] {
  return courses.filter(course => (filter.topic === 'all' || course.topic === filter.topic) && course.lessons >= filter.minLessons);
}

singleText читает все значения имени и требует ровно одну строку. Такой договор обнаруживает случайный второй select с тем же именем и не выбирает произвольный первый вариант молча. Если вместо строки получен File, функция тоже отказывает. Возможность строкового или файлового результата описана в FormData.get.

Тема проверяется сравнением с тремя допустимыми литералами. После этой ветки инструмент может связывать строку с TopicFilter. Никакого as TopicFilter не требуется. Это реальное правило значения, которое исполняется вместе с приложением.

Количество сначала остаётся строкой. Мы удаляем крайние пробелы, затем принимаем десятичную запись нуля или положительного целого без ведущих нулей. Отрицательное число, дробь, пустая строка и экспоненциальная запись не относятся к выбранному формату. Это строгая политика учебного поля, а не универсальные возможности HTML-number.

После проверки формы строки Number создаёт число. Number.isSafeInteger добавляет ограничение надёжной целочисленной арифметики. Очень длинная строка цифр может соответствовать регулярному выражению, но не представляться точно обычным number. Поэтому смысловая проверка не заканчивается на внешнем виде строки.

Применение и сообщение отказа

Полный src/app.ts соединяет форму с представлением:

import { courses } from './data.js';
import { renderCourses } from './view.js';
import { readFilter, applyFilter } from './form.js';

const list = document.querySelector('#catalog-list');
const status = document.querySelector('#catalog-status');
const form = document.querySelector('#catalog-controls');
if (!(list instanceof HTMLUListElement) || !(status instanceof HTMLElement) || !(form instanceof HTMLFormElement)) {
  throw new Error('Не найдены элементы каталога');
}
renderCourses(list, status, courses);
form.addEventListener('submit', event => {
  event.preventDefault();
  try { renderCourses(list, status, applyFilter(courses, readFilter(form))); }
  catch (error: unknown) { status.textContent = error instanceof Error ? error.message : 'Ошибка формы'; }
});

Начальный список содержит четыре курса и 60 уроков. Выбор frontend и минимума 16 должен дать JavaScript и HTML/CSS: два курса, 36 уроков. Выбор publishing с минимумом 16 даёт пустой список и сумму ноль. Это корректный результат отбора, а не ошибка данных.

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

В отличие от ошибки начального DOM-шаблона, неправильный пользовательский отбор является ожидаемым сценарием. Поэтому он показывается рядом с формой, а не только бросается в консоль. При этом общий catch не следует считать совершенным обработчиком всех ошибок продукта: в большом компоненте нужно отделить ошибку входа от сбоя представления.

Строка ноль и пустое значение

Преобразование Number('') даёт числовой результат, который легко принять за осмысленный минимум. Мы проверяем исходную строку до преобразования, поэтому пустота не становится случайным режимом «показать всё». Строка '0', напротив, является явно допустимым значением и превращается в ноль.

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

При потребности принимать ведущие нули правило можно изменить. Но необходимо сделать это явно и показать, что '016' нормализуется в 16. Аналогично можно выбрать дробный минимум для другой предметной модели. У количества уроков дробь не имеет нужного смысла, поэтому текущий пример использует целое число.

Нативная проверка формы может остановить submit до нашего обработчика, если required/min/step нарушены обычным вводом. Это удобная первая граница. Наш парсер остаётся нужен для самостоятельных вызовов и несогласованной разметки. Не нужно утверждать, что каждая описанная ошибка обязательно пройдёт через submit в каждом браузерном сценарии.

Имя поля является частью внешнего формата

Связь между HTML и readFilter держится на name, а не на видимой подписи label. Если поменять name="minLessons" на name="minimum", сохранив функцию, парсер не увидит ожидаемое поле. Такое нарушение должно закончиться отказом входа; не следует подставлять ноль, маскируя ошибку шаблона как выбор пользователя.

FormData учитывает правила успешных полей формы. Отключённое поле или отключённая полевая группа не передаются, как объяснено в Using FormData Objects. Поэтому в следующем шаге при блокировке интерфейса на время загрузки мы будем включать fieldset до чтения фильтра. Disabled — это не только изменение внешнего вида.

Для текущего снимка все поля включены. Если позже добавить checkbox или несколько одинаковых имён для списка тем, нужно изменить и договор чтения. Нынешний singleText намеренно требует одну строку. Он не является универсальным парсером любой формы: его строгость соответствует двум одиночным полям нашего каталога.

Важно также не смешивать minimum с lessons конкретного курса. В Filter разрешён ноль, потому что это нижний порог. В Course количество должно быть положительным, потому что запись представляет существующую серию. Одинаковый примитивный number не означает одинаковых предметных ограничений. Их выражают разные функции, принимающие реальные входные значения.

Например, frontend/12 должен сохранить три курса и 48 уроков, поскольку нижняя граница включительная. Frontend/20 оставляет один курс и 20 уроков. Эти ожидаемые результаты следуют из >=, а не из формы input. При изменении оператора на > тот же введённый порог даст другую выборку, хотя типы останутся прежними.

Текущий Filter не хранит исходное текстовое написание порога. После успешного чтения полезен числовой смысл, а не пробелы вокруг него. Если продукту нужно восстановить именно введённый текст после ошибки, это отдельное состояние поля формы; его не следует незаметно переносить в доменную модель отбора.

Состояние формы и состояние каталога

Filter является небольшим внутренним объектом. Он не содержит ссылки на DOM и не знает, какое поле пользователь редактирует сейчас. Поэтому applyFilter может работать с таким объектом независимо от формы. Чтение входа и выбор курсов имеют разные обязанности.

Применение фильтра не меняет исходные данные: filter возвращает новый массив ссылок на подходящие записи. Количество уроков у курса остаётся прежним. После смены темы или минимального значения нужно новое успешное submit, поскольку снимок не обновляет список на каждое нажатие клавиши.

Также введённые значения не записываются в URL или localStorage. Для такого поведения понадобились бы сериализация и отдельная проверка сохранённого состояния. Тип Filter помогает определить форму, но не создаёт механизм хранения автоматически.

Проследите сценарий frontend/16 до сводки 2/36, затем publishing/16 до 0/0. Данные не сломались, изменилось условие выбора. В следующем уроке заменим локальный массив источником JSON по HTTP, сохранив эту модель формы и введя новые ошибки загрузки.

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