← Все проекты

Booking widget — один виджет, три системы типов

Виджет бронирования отеля, который я написал трижды — на Vue 3 + TypeScript, на Effect-TS и на Elm — чтобы измерить, сколько дыр в корректности оставляет компилятор TypeScript, что даёт связка Effect + TypeScript, и как с этим справляется Elm, где эти дыры закрывает сам компилятор.

Vue 3TypeScriptEffect-TSfoldkitElmTEAVite

Зачем

Это маленький виджет бронирования номера в отеле: выбор дат в календаре, подбор номера под число гостей, занятость на выбранные даты, расчёт ночей и стоимости, переключение языка (ru/en) и темы, поток «выбор → проверка → подтверждение». Фич немного — и это сознательно: цель не в количестве экранов, а в качестве кода и архитектуры.

Базовую версию я собрал на Vue 3 + TypeScript в строгом режиме. А дальше начался эксперимент, ради которого всё и затевалось: я прошёл аудит этой версии и нашёл 13 дыр в корректности — мест, где тип допускает невозможное состояние, переход держится на договорённости, или правило живёт в одном месте UI и нигде больше. Все тринадцать компилируются в strict. После этого я переписал тот же виджет на двух системах с сильными типами — Effect-TS (TEA + Effect) и Elm — и посчитал, сколько дыр каждая закрывает структурно (плохое состояние невыразимо / переход — ошибка компиляции), а сколько — лишь «на практике» (правило централизовано в чистом коде, но всё ещё написано руками).

Тезис, который я хотел проверить: TypeScript не гарантирует корректность программы после компиляции.

Код и живое демо

Подробный разбор «аудит → архитектура» по каждой из 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 закрывает почти всё по построению, но требует церемонии и отдельного рантайма.

Главный вывод — методологический: «работает и проходит тесты» и «некорректное состояние невыразимо» — это два разных уровня гарантий. Один и тот же продукт, написанный трижды, делает эту разницу видимой и измеримой, а не предметом веры.