Strict mode (TypeScript)
Публичные типы Triggery спроектированы под самую строгую практическую конфигурацию TypeScript. Пакет компилируется с strict: true, noUncheckedIndexedAccess: true, exactOptionalPropertyTypes: true и noImplicitOverride: true. Причина не идеологическая — эти флаги ловят большинство багов, на которые иначе натыкаются во время feature-фриза. Эта страница объясняет, что флаги значат для твоего кода, какие паттерны читаются естественно, и предлагает несколько альтернатив as any.
Рекомендуемый tsconfig
Заголовок раздела «Рекомендуемый tsconfig»Минимум, который мы рекомендуем приложению, использующему Triggery:
skipLibCheck: false — то, что большинство команд отключают; мы держим его включённым, потому что .d.ts-файлы Triggery намеренно дёшевы для проверки, а ранний отлов несовпадений между либами стоит этой секунды билда.
Почему всё опциональное
Заголовок раздела «Почему всё опциональное»Когда читаешь типы Triggery в первый раз, два момента удивляют:
conditions.userимеет типUser | undefined, даже когдаuserперечислен вrequired.actions.fooимеет тип(payload: Foo) => void | undefined, поэтому каждое место вызова выглядит какactions.foo?.(payload).
Обе вещи намеренны. Обе кодируют реальность рантайма, которую strict mode потом делает видимой.
Conditions: required — это рантайм-гейт, а не статическая гарантия
Заголовок раздела «Conditions: required — это рантайм-гейт, а не статическая гарантия»required: ['user'] не обещает системе типов, что conditions.user существует, — он обещает рантайму, что обработчик не запустится, пока user не зарегистрирован. У разных слоёв системы разный взгляд:
- Диспетчер проверяет required-набор и либо вызывает обработчик, либо записывает
'skipped'. - TypeScript не знает, какой
<UserProvider>смонтирован. Из статической программы он не может предсказать, есть лиuserна момент вызова обработчика.
Поэтому API выбирает безопасность: conditions.user имеет тип User | undefined, и обработчик обязан сузить.
Builder API уже доступен с v0.10 — import { createTrigger } from '@triggery/core/builder' и пиши createTrigger<S>().require('user').handle(ctx => ...). Это автоматически типизирует ctx.conditions.user как User без ручного гарда. Семантика рантайма не меняется.
Actions: каждое действие опционально, потому что реакторы могут отсутствовать
Заголовок раздела «Actions: каждое действие опционально, потому что реакторы могут отсутствовать»Триггер не владеет своими реакторами. Есть ли у showToast зарегистрированный обработчик — зависит от того, в дереве ли <NotificationLayer>. Strict mode это кодирует:
Каждый ключ действия опционален. Места вызова используют optional chaining:
Это не шум — это полезный рантайм-контракт. Обработчик работает независимо от того, смонтирован ли реактор showToast; ничего не падает, когда реактора нет. Можно читать как «отправь это действие, если кто-то слушает». Тесты логики триггеров, не рендерящие UI, этим пользуются: обработчик прекрасно работает с нулём зарегистрированных действий.
ESLint-правило actions-optional-chaining флагает actions.showToast(...) без ?., чтобы паттерн оставался единообразным.
noUncheckedIndexedAccess на практике
Заголовок раздела «noUncheckedIndexedAccess на практике»Когда включаешь этот флаг, все обращения по индексу к массиву становятся T | undefined. Внутренний код Triggery написан под него — но и твой код в обработчиках может с этим столкнуться:
Это не про Triggery — это общая TS-гигиена — но в коде обработчиков встречается достаточно часто, чтобы упомянуть. Лечение — всегда сужение, никогда не non-null-ассершен.
exactOptionalPropertyTypes и void-события
Заголовок раздела «exactOptionalPropertyTypes и void-события»С exactOptionalPropertyTypes: true становится видна разница между payload?: T и payload?: T | undefined. EventOf<S> у Triggery использует первую форму:
payload всегда присутствует в члене discriminated union. Для void-событий его значение undefined (рантайм пихает туда undefined), но само свойство существует. Это значит:
Правильный способ прочитать payload void-события — не читать его. Если у события есть варианты, моделируй их как discriminated payload, а не как отсутствие payload.
EmptyRecord — случай «нет действий»
Заголовок раздела «EmptyRecord — случай «нет действий»»У триггера могут быть события и условия без действий — чисто аналитика, например. TS-форма actions тогда — {} плюс цепочка модификаторов. С пустыми объектными типами в TS есть мелкая ловушка, которую библиотека закрывает через алиас EmptyRecord:
Record<string, never> — технически верный способ сказать «ключи запрещены» — отличается от {} (что значит «любое non-null значение»). Большинство пользователей EmptyRecord напрямую не пишут; важно, что случай пустых действий компилируется и ведёт себя ожидаемо:
Если нужно назвать «схема без действий» в generic, EmptyRecord — это алиас:
Альтернативы as any
Заголовок раздела «Альтернативы as any»Несколько паттернов, где strict mode заставляет тянуться к as any. У каждого есть безопасная альтернатива.
Брендирование без потери типобезопасности
Заголовок раздела «Брендирование без потери типобезопасности»Брендированные типы (см. Типизация схемы → Брендированные id) хотят, чтобы string стал ChannelId. Естественное желание — написать as any:
Правильная форма идёт через конструктор:
Теперь каст живёт в одной функции. Если когда-нибудь появится валидация — она пойдёт туда.
Generic-хелперы по схемам
Заголовок раздела «Generic-хелперы по схемам»Тестовый хелпер хочет принимать «любой триггер» и проверять его инспектор. Не хватайся за as any:
Generic-версия сохраняет инфу о типах в точке вызова — expectFired(messageTrigger, 'wrong-name') будет TS-ошибкой.
Сужение по event.name
Заголовок раздела «Сужение по event.name»Когда триггер перечисляет несколько событий, иногда хочется вызвать хелпер, специфичный для одного из них:
Discriminated union по name всегда сужает payload за тебя — в этом и весь смысл EventOf<S>.
«Хочу, чтобы триггер просто где-то существовал»
Заголовок раздела ««Хочу, чтобы триггер просто где-то существовал»»Если когда-нибудь захочется написать let trigger: Trigger<any> из-за динамической схемы — ты вышел за пределы типизированной поверхности и должен тянуться к рантайм-внутренностям:
Trigger<TriggerSchema> — максимально-generic-тип; именно его рантайм хранит внутри. Это не any; это «любая валидная схема».
useUnknownInCatchVariables и async-обработчики
Заголовок раздела «useUnknownInCatchVariables и async-обработчики»Паттерн async-обработчика в Triggery взаимодействует с useUnknownInCatchVariables: true:
Рантайм глотает ошибки обработчика и записывает 'errored' в инспектор — ловить не обязательно. Но если ловишь — сужай переменную; не клади на неё as Error вслепую.
Когда правда нужно выйти из strict
Заголовок раздела «Когда правда нужно выйти из strict»Два случая. Оба должны быть маленькими.
- Обёртка над не-TS-библиотекой, возвращающей
any. Оберни один раз на границе; кастани к типизированной форме; всё дальше — strict. - Тестовые фикстуры.
as unknown as Userдля умышленно неполной фикстуры — нормально, только пусть это будет тестовый код, не продакшен.
На всё остальное паттерны выше заменяют каст.