Перейти к содержимому
GitHubXDiscord

Strict mode (TypeScript)

Публичные типы Triggery спроектированы под самую строгую практическую конфигурацию TypeScript. Пакет компилируется с strict: true, noUncheckedIndexedAccess: true, exactOptionalPropertyTypes: true и noImplicitOverride: true. Причина не идеологическая — эти флаги ловят большинство багов, на которые иначе натыкаются во время feature-фриза. Эта страница объясняет, что флаги значат для твоего кода, какие паттерны читаются естественно, и предлагает несколько альтернатив as any.

Минимум, который мы рекомендуем приложению, использующему Triggery:

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",

    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "useUnknownInCatchVariables": true,

    "jsx": "react-jsx",
    "skipLibCheck": false
  }
}

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, и обработчик обязан сузить.

handler({ conditions, actions, check }) {
  // ✓ Explicit narrow — TS knows conditions.user is User after this.
  if (!conditions.user) return;
  actions.greet?.({ name: conditions.user.name });

  // ✓ check.is narrows internally (NonNullable in the predicate type).
  if (check.is('user', u => u.isActive)) {
    actions.notify?.({ userId: conditions.user.id });
  }
}

Builder API в V1.1 это меняет — createTrigger<S>().require(['user']).handle(ctx => ...) типизирует ctx.conditions.user как User без гарда. Семантика рантайма не меняется.

Actions: каждое действие опционально, потому что реакторы могут отсутствовать

Заголовок раздела «Actions: каждое действие опционально, потому что реакторы могут отсутствовать»

Триггер не владеет своими реакторами. Есть ли у showToast зарегистрированный обработчик — зависит от того, в дереве ли <NotificationLayer>. Strict mode это кодирует:

type ActionsCtx<A> = { readonly [K in keyof A]?: ActionFn<A[K]> } & { /* modifiers */ };

Каждый ключ действия опционален. Места вызова используют optional chaining:

actions.showToast?.({ title: event.payload.author });
actions.debounce(800).playSound?.();
actions.throttle(2000).updateBadge?.(event.payload.channelId);

Это не шум — это полезный рантайм-контракт. Обработчик работает независимо от того, смонтирован ли реактор showToast; ничего не падает, когда реактора нет. Можно читать как «отправь это действие, если кто-то слушает». Тесты логики триггеров, не рендерящие UI, этим пользуются: обработчик прекрасно работает с нулём зарегистрированных действий.

ESLint-правило actions-optional-chaining флагает actions.showToast(...) без ?., чтобы паттерн оставался единообразным.

Когда включаешь этот флаг, все обращения по индексу к массиву становятся T | undefined. Внутренний код Triggery написан под него — но и твой код в обработчиках может с этим столкнуться:

handler({ event }) {
  const lines = event.payload.text.split('\n');
  const first = lines[0];           // string | undefined under noUncheckedIndexedAccess
  if (!first) return;
  // …
}

Это не про Triggery — это общая TS-гигиена — но в коде обработчиков встречается достаточно часто, чтобы упомянуть. Лечение — всегда сужение, никогда не non-null-ассершен.

С exactOptionalPropertyTypes: true становится видна разница между payload?: T и payload?: T | undefined. EventOf<S> у Triggery использует первую форму:

type EventOf<S> = {
  [K in EventKey<S>]: { readonly name: K; readonly payload: EventMap<S>[K] };
}[EventKey<S>];

payload всегда присутствует в члене discriminated union. Для void-событий его значение undefined (рантайм пихает туда undefined), но само свойство существует. Это значит:

events: { 'app:ready': void };

handler({ event }) {
  if (event.name === 'app:ready') {
    // event.payload is `void` — i.e. you can't read fields off it.
    // event.payload?.foo  → TS error: foo doesn't exist on void
  }
}

Правильный способ прочитать payload void-события — не читать его. Если у события есть варианты, моделируй их как discriminated payload, а не как отсутствие payload.

У триггера могут быть события и условия без действий — чисто аналитика, например. TS-форма actions тогда — {} плюс цепочка модификаторов. С пустыми объектными типами в TS есть мелкая ловушка, которую библиотека закрывает через алиас EmptyRecord:

From @triggery/core/types.ts
export type EmptyRecord = Record<string, never>;

export type ActionMap<S> =
  S['actions'] extends Record<string, unknown> ? S['actions'] : EmptyRecord;

Record<string, never> — технически верный способ сказать «ключи запрещены» — отличается от {} (что значит «любое non-null значение»). Большинство пользователей EmptyRecord напрямую не пишут; важно, что случай пустых действий компилируется и ведёт себя ожидаемо:

createTrigger<{
  events: { 'analytics:event': { kind: string } };
  // no conditions, no actions
}>({
  id: 'analytics',
  events: ['analytics:event'],
  handler({ event, actions }) {
    // actions still has the modifier chain (debounce/throttle/defer)
    // even though there are no action methods to chain into.
    sendBeacon(event.payload);
  },
});

Если нужно назвать «схема без действий» в generic, EmptyRecord — это алиас:

import type { EmptyRecord } from '@triggery/core';

type AnalyticsSchema = {
  events: { 'analytics:event': { kind: string } };
  actions: EmptyRecord;
};

Несколько паттернов, где strict mode заставляет тянуться к as any. У каждого есть безопасная альтернатива.

Брендирование без потери типобезопасности

Заголовок раздела «Брендирование без потери типобезопасности»

Брендированные типы (см. Типизация схемы → Брендированные id) хотят, чтобы string стал ChannelId. Естественное желание — написать as any:

const id = stringFromSomewhere() as any as ChannelId;   // ✗

Правильная форма идёт через конструктор:

const channelId = (s: string): ChannelId => s as ChannelId;   // single `as`, single place

const id = channelId(stringFromSomewhere());                  // ✓

Теперь каст живёт в одной функции. Если когда-нибудь появится валидация — она пойдёт туда.

Тестовый хелпер хочет принимать «любой триггер» и проверять его инспектор. Не хватайся за as any:

// ✗ Sledgehammer
function expectFired(trigger: any, eventName: any) {
  expect(trigger.inspect()?.eventName).toBe(eventName);
}

// ✓ Generic with the public types
import type { EventKey, Trigger, TriggerSchema } from '@triggery/core';

function expectFired<S extends TriggerSchema>(trigger: Trigger<S>, eventName: EventKey<S>) {
  expect(trigger.inspect()?.eventName).toBe(eventName);
}

Generic-версия сохраняет инфу о типах в точке вызова — expectFired(messageTrigger, 'wrong-name') будет TS-ошибкой.

Когда триггер перечисляет несколько событий, иногда хочется вызвать хелпер, специфичный для одного из них:

handler({ event }) {
  // ✗ "I know this is the urgent variant"
  showUrgent(event.payload as UrgentPayload);

  // ✓ Discriminate
  if (event.name === 'urgent-message') {
    showUrgent(event.payload);  // event.payload is narrowed
  }
}

Discriminated union по name всегда сужает payload за тебя — в этом и весь смысл EventOf<S>.

«Хочу, чтобы триггер просто где-то существовал»

Заголовок раздела ««Хочу, чтобы триггер просто где-то существовал»»

Если когда-нибудь захочется написать let trigger: Trigger<any> из-за динамической схемы — ты вышел за пределы типизированной поверхности и должен тянуться к рантайм-внутренностям:

import type { Trigger, TriggerSchema } from '@triggery/core';

function disable(trigger: Trigger<TriggerSchema>) {
  trigger.disable();
}

Trigger<TriggerSchema> — максимально-generic-тип; именно его рантайм хранит внутри. Это не any; это «любая валидная схема».

Паттерн async-обработчика в Triggery взаимодействует с useUnknownInCatchVariables: true:

async handler({ event, signal, actions }) {
  try {
    const res = await fetch(event.payload.url, { signal });
    actions.show?.(await res.json());
  } catch (err) {
    // err is `unknown` under this flag
    if (err instanceof DOMException && err.name === 'AbortError') return;
    if (err instanceof Error) actions.reportError?.(err.message);
    else actions.reportError?.(String(err));
  }
}

Рантайм глотает ошибки обработчика и записывает 'errored' в инспектор — ловить не обязательно. Но если ловишь — сужай переменную; не клади на неё as Error вслепую.

Два случая. Оба должны быть маленькими.

  • Обёртка над не-TS-библиотекой, возвращающей any. Оберни один раз на границе; кастани к типизированной форме; всё дальше — strict.
  • Тестовые фикстуры. as unknown as User для умышленно неполной фикстуры — нормально, только пусть это будет тестовый код, не продакшен.

На всё остальное паттерны выше заменяют каст.