Strict mode (TypeScript)
Triggery’s public types are designed against the strictest practical TypeScript configuration. The package compiles with strict: true, noUncheckedIndexedAccess: true, exactOptionalPropertyTypes: true, and noImplicitOverride: true. The reason isn’t ideology — it’s that these flags catch most of the bugs people would otherwise hit during a feature freeze. This page explains what the flags imply for your code, the patterns that read naturally, and a few as any alternatives.
The recommended tsconfig
Section titled “The recommended tsconfig”The minimum we recommend for an app that uses Triggery:
skipLibCheck: false is the one most teams turn off — we keep it on because Triggery’s .d.ts files are intentionally cheap to type-check, and finding library mismatches early is worth the second of build time.
Why everything is optional
Section titled “Why everything is optional”When you read a Triggery type for the first time, two things look surprising:
conditions.userisUser | undefined, even whenuseris listed inrequired.actions.foois(payload: Foo) => void | undefined, so every call site looks likeactions.foo?.(payload).
Both are intentional. Both encode a runtime reality that strict mode then makes visible.
Conditions: required is a runtime gate, not a static guarantee
Section titled “Conditions: required is a runtime gate, not a static guarantee”required: ['user'] doesn’t promise the type system that conditions.user exists — it promises the runtime that the handler won’t run unless user is registered. Different layers of the system have different views:
- The dispatcher checks the required set and either calls the handler or records
'skipped'. - TypeScript doesn’t know which
<UserProvider>has mounted. It can’t predict whetheruseris present at handler-call-time from the static program alone.
Hence the API picks safety: conditions.user is User | undefined, and the handler must narrow.
The builder API (shipped in v0.10 — import from @triggery/core/builder) changes this: createTrigger<S>().require('user').handle(ctx => ...) types ctx.conditions.user as User automatically, without the manual guard. Runtime semantics don’t change.
Actions: every action is optional because reactors may be absent
Section titled “Actions: every action is optional because reactors may be absent”A trigger doesn’t own its reactors. Whether showToast actually has a registered handler depends on whether <NotificationLayer> is in the tree. Strict mode encodes that:
Every action key is optional. Call sites use optional chaining:
This is not noise — it’s a useful runtime contract. The handler runs whether or not a reactor for showToast is mounted; nothing crashes when one isn’t. You can read it as “fire this action if anyone is listening”. Tests of trigger logic that don’t render the UI take advantage of this: the handler runs fine with zero registered actions.
The eslint rule actions-optional-chaining flags actions.showToast(...) without the ?. so the pattern stays consistent.
noUncheckedIndexedAccess in practice
Section titled “noUncheckedIndexedAccess in practice”When you turn this flag on, all array index lookups become T | undefined. Triggery’s internal code is written for that — but your handler code can hit it too:
This is unrelated to Triggery — it’s general TS hygiene — but it shows up often enough in handler code that it’s worth mentioning. The fix is always a narrow, never a non-null assertion.
exactOptionalPropertyTypes and void events
Section titled “exactOptionalPropertyTypes and void events”With exactOptionalPropertyTypes: true, the difference between payload?: T and payload?: T | undefined becomes visible. Triggery’s EventOf<S> uses the first form:
payload is always present on the discriminated-union member. For void events its value is undefined (the runtime stuffs undefined in there), but the property exists. This means:
The right way to read a void event payload is to not read it. If your event has variants, model them as discriminated payloads, not as the absence of a payload.
EmptyRecord — the “no actions” case
Section titled “EmptyRecord — the “no actions” case”A trigger might have events and conditions but no actions — pure analytics, say. The TS shape of actions is then {} plus the modifier chain. There’s a tiny gotcha with empty object types in TS that the library handles via an EmptyRecord alias:
Record<string, never> is the technically correct way to say “no keys allowed” — distinct from {} (which means “any non-null value”). Most users never type EmptyRecord directly; what matters is that the empty-actions case compiles and behaves as expected:
If you do need to name “schema with no actions” in a generic, EmptyRecord is the alias:
Alternatives to as any
Section titled “Alternatives to as any”A few patterns where strict mode makes people reach for as any. Each has a safer alternative.
”Branding” without losing type safety
Section titled “”Branding” without losing type safety”Branded types (see Schema typing → Branded ids) want a string to become a ChannelId. The natural urge is to write as any:
The right form goes through a constructor:
Now the cast lives in one function. If validation ever shows up, that’s where it goes.
Generic helper functions over schemas
Section titled “Generic helper functions over schemas”A test helper wants to accept “any trigger” and assert against its inspector. Don’t reach for as any:
The generic version preserves type info at the call site — expectFired(messageTrigger, 'wrong-name') is a TS error.
Narrowing on event.name
Section titled “Narrowing on event.name”When a trigger lists multiple events, you sometimes want to call a helper specific to one of them:
The discriminated union over name always narrows payload for you — that’s the whole point of EventOf<S>.
”I just want the trigger to exist somewhere”
Section titled “”I just want the trigger to exist somewhere””If you ever feel the need to write let trigger: Trigger<any> because the schema is dynamic, you’ve stepped outside the typed surface and should reach for the runtime internals instead:
Trigger<TriggerSchema> is the maximally-generic type — it’s what the runtime stores internally. It’s not any; it’s “any valid schema”.
useUnknownInCatchVariables and async handlers
Section titled “useUnknownInCatchVariables and async handlers”The async handler pattern in Triggery interacts with useUnknownInCatchVariables: true:
The runtime swallows handler errors and records 'errored' in the inspector — you don’t have to catch. But if you do, narrow on the variable; don’t as Error it blindly.
When you actually do need to escape strictness
Section titled “When you actually do need to escape strictness”Two cases. Both should be small.
- Wrapping a non-TS library that returns
any. Wrap once at the boundary; cast to a typed shape; everything downstream is strict. - Test fixtures. A
as unknown as Userfor a deliberately incomplete fixture is fine — make sure it’s in test code and not production.
For everything else, the patterns above replace the cast.