Booking widget — один виджет, три системы типов
Виджет бронирования отеля, который я написал трижды — на Vue 3 + TypeScript, на Effect-TS и на Elm — чтобы измерить, сколько дыр в корректности оставляет компилятор TypeScript, что даёт связка Effect + TypeScript, и как с этим справляется Elm, где эти дыры закрывает сам компилятор.
Зачем
Это маленький виджет бронирования номера в отеле: выбор дат в календаре, подбор номера под число гостей, занятость на выбранные даты, расчёт ночей и стоимости, переключение языка (ru/en) и темы, поток «выбор → проверка → подтверждение». Фич немного — и это сознательно: цель не в количестве экранов, а в качестве кода и архитектуры.
Базовую версию я собрал на Vue 3 + TypeScript в строгом режиме. А дальше начался эксперимент, ради которого всё и затевалось: я прошёл аудит этой версии и нашёл 13 дыр в корректности — мест, где тип допускает невозможное состояние, переход держится на договорённости, или правило живёт в одном месте UI и нигде больше. Все тринадцать компилируются в strict. После этого я переписал тот же виджет на двух системах с сильными типами — Effect-TS (TEA + Effect) и Elm — и посчитал, сколько дыр каждая закрывает структурно (плохое состояние невыразимо / переход — ошибка компиляции), а сколько — лишь «на практике» (правило централизовано в чистом коде, но всё ещё написано руками).
Тезис, который я хотел проверить: TypeScript не гарантирует корректность программы после компиляции.
Код и живое демо
- Vue 3 + TypeScript (база) — репозиторий · живое демо
- Effect-TS / foldkit (порт) — репозиторий · живое демо
- Elm (порт) — репозиторий · живое демо
Подробный разбор «аудит → архитектура» по каждой из 13 дыр — в COMPARISON.md внутри репозиториев-портов.
Базовый виджет: Vue 3 + TypeScript
Версия-эталон — <script setup> + TypeScript в strict, на Vite. Без UI-библиотек, без стейт-менеджеров, без date-библиотек: всё на нативных API и Composition API.
Логика отделена от представления. Домен (src/domain) — чистые функции без единого импорта Vue: расчёт ночей, подбор номеров под гостей, валидация, детерминированная «занятость» (без Math.random, поэтому тесты стабильны). Слой данных спрятан за порт BookingApi с адаптерами (мок с сетевой задержкой по умолчанию, http с маппингом DTO→домен, фабрика по VITE_API_URL) — Dependency Inversion: чтобы подключить реальный бэкенд, меняется одна строка, а в тестах адаптер подменяется фейком. Реактивный слой — composable useBooking через provide/inject с типизированным InjectionKey: состояние маленькое и привязано к одному инстансу виджета, поэтому Pinia здесь — лишний вес. UI-kit по атомарному дизайну (UiButton, UiBadge, UiSurface, UiStepper, UiSegmented, UiStack, UiSplit): организмы вроде RoomCard и BookingSummary собраны на ~80% из этих примитивов, так что стилем кнопок и отступов владеет один слой.
Покрытие: 47 тестов Vitest (домен, адаптеры, стор, UI-kit, smoke виджета) и 4 E2E на Playwright (happy-path в реальном браузере). На этой версии я и провёл аудит из 13 дыр — она работает корректно, но многие инварианты держатся на дисциплине, а не на типах.
Эксперимент: одна архитектура, три системы типов
Оба порта построены по The Elm Architecture — одна иммутабельная Model, один чистый тотальный update, эффекты как значения. Домен, данные о занятости, поток и i18n — те же. Разница только в том, как смоделировано состояние и как выражены переходы. Итог по аудиту из 13 дыр:
| Срез | Vue 3 + TS (база) | Effect-TS / foldkit | Elm |
|---|---|---|---|
| Дыры закрыты структурно | — (эталон, 13 открыто) | 9 из 13 | 12 из 13 |
| Закрыты «на практике» | — | 4 | 1 |
| Осталось открытыми | 13 | 0 | 0 |
| Невозможные состояния | Date | null × 2 поля независимы; флаг status оторван от валидности |
sum-типы; Reviewing/Confirmed несут ValidBooking (smart-constructor) |
то же + непрозрачный BookableDate: прошлую дату нельзя построить |
| Граница I/O | as RoomDto[], as RoomId — проверка выключена |
Schema есть доменный тип: декодер и тип не расходятся |
Json.Decode: плохой вход не доходит до домена |
| Даты | локальные getMonth/Date, риск off-by-one по TZ |
CalendarDate — день без времени и зоны |
justinmimbs/date — день без времени и зоны |
| Ошибки | confirm() не делает эффекта, упасть не может |
типизированный канал catchTags → исчерпывающие сообщения |
ADT SubmitError (409 → RoomTaken), сообщение на причину |
| Исчерпывающность | тернарники locale === 'ru' ? … молча ловят 3-ю локаль |
Match.exhaustive — новая локаль ломает компиляцию |
case по Locale — то же |
| Тесты | 47 Vitest + 4 Playwright | 32 (Story/Scene + property-based на fast-check) | 77 elm-test (юнит / fuzz / update-истории) |
Кратко, что показал замер. Дыры представления и исчерпывающности (несвязанные поля, голый флаг статуса, неполные матчи) обе системы закрывают чисто — sum-типы и тотальный update делают плохое состояние невыразимым. Effect вырывается вперёд на границе I/O: Schema является доменным типом, поэтому декодер и тип не могут разойтись (у ручных декодеров это два разных объявления). Elm идёт дальше всех по аудиту (12 из 13) за счёт непрозрачного BookableDate, который делает «прошлую дату» буквально непостроимой, — ценой церемонии wrap/unwrap на границах. Единственная дыра, что у Elm осталась «на практике», — отмена устаревших запросов: отмена это рантайм-эффект, её нельзя запретить типом.
Решения, которые я принял на этом проекте
Сначала аудит базы, потом порты
Я не стал переписывать «на глаз». Сперва — формальный аудит Vue-версии на 13 конкретных дыр с severity и привязкой к строкам, и только потом порты, каждая дыра в которых получает вердикт: закрыта структурно, на практике или открыта.
Почему так: иначе сравнение скатывается в «мне на Elm приятнее». Заранее зафиксированный чек-лист превращает вкусовщину в измерение — по каждой из 13 строк видно, что именно изменилось и за счёт какой конструкции, а не «в целом стало надёжнее».
«Невозможное состояние» проверяется конструированием, а не проверкой
В портах валидная бронь существует только как ValidBooking из smart-конструктора, и её несут именно те варианты фазы (Reviewing/Confirmed), где она нужна. «Подтверждено без валидных данных» нельзя собрать.
Почему так: во Vue та же гарантия держалась на ручных isValid-проверках в review()/confirm() и условном рендере — то есть на том, что разработчик не забудет. Перенос правила в форму типа убирает целый класс «забыл проверить» без единого юнит-теста на это.
Один и тот же порт данных во всех трёх версиях
Везде данные идут через порт с мок- и http-адаптерами, переключаемыми одной переменной окружения. Менялась реализация декодирования на границе, но не сам контракт «домен не знает, откуда данные».
Почему так: эксперимент должен мерить систему типов, а не разницу в архитектуре доступа к данным. Общий порт-адаптер удерживает все прочие переменные постоянными — и заодно показывает, что Dependency Inversion одинаково ложится и на Vue-composable, и на TEA.
Что это показывает
Для меня это не «Elm победил» — у каждой версии своя цена. Vue + TS даёт самую быструю и привычную разработку, но половина инвариантов в нём — договорённости, которые strict не подсвечивает. Effect-TS приносит структурную надёжность в экосистему TypeScript, и сильнее всего — ровно на границе с внешним миром. Elm закрывает почти всё по построению, но требует церемонии и отдельного рантайма.
Главный вывод — методологический: «работает и проходит тесты» и «некорректное состояние невыразимо» — это два разных уровня гарантий. Один и тот же продукт, написанный трижды, делает эту разницу видимой и измеримой, а не предметом веры.