Typed filters (`@wats/core/filtersTyped`)
The typed, type-narrowing filter surface: brand, combinators, and built-in predicates over TypedUpdate.
active
Watch filters route real updates in the playground.
Typed filters are branded, narrowing predicates over the TypedUpdate union emitted by normalizeWebhookEnvelope. TypeScript checks the narrowing across handler boundaries. The router and listener layers consume them.
Import surface
import {
// Kind filters (each is a TypedFilter by itself AND a namespace)
message,
status,
account,
call,
group,
userPreferences,
system,
chatOpened,
unknown as unknownUpdate,
// Combinators
and,
or,
not,
custom,
// Construction / introspection
createTypedFilter,
isTypedFilter,
FILTER_BRAND,
// Error type
FilterValidationError,
// Types
type TypedFilter,
type FilterValidationErrorCode
} from "@wats/core/filtersTyped";The same surface is reachable as a namespace on the root entrypoint:
import { filtersTyped } from "@wats/core";
filtersTyped.message.textMatches(/^hello/);Two filter surfaces coexist (transitional)
@wats/core ships TWO filter surfaces side-by-side:
| Surface | Import | Shape |
|---|---|---|
| Legacy | @wats/core/filters | untyped and/or/not + hasMessageText, messageTextContains, messageFromWaId, hasMessageStatus, messageStatusIn |
| Typed | @wats/core/filtersTyped (this doc) | branded TypedFilter<T> + typed and/or/not/custom + message.* / status.* built-ins |
The surfaces are independent — neither imports from the other. The names and / or / not exist on BOTH with different signatures and different brand semantics; do not mix them in one composition chain. Pick one per call site and migrate at your own pace.
Deprecated. The legacy
@wats/core/filterssurface (UpdateFilter, the untypedand/or/not, and thehasMessageText/messageTextContains/messageFromWaId/hasMessageStatus/messageStatusInbuilt-ins) is now marked@deprecatedat the type level and is scheduled for removal from the@wats/corebarrel in the next minor release. New code should use the typedfiltersTypedsurface on this page; the legacyand/or/notcombinators will no longer be exported once the barrel is pruned. The typed and legacy surfaces remain independent until then — do not mix them in one composition chain.
TypedFilter brand
TypedFilter<T extends TypedUpdate> is a branded object that identifies as a filter across module boundaries. The brand symbol is interned with Symbol.for("@wats/core/filter-brand"), so filters built in a consumer workspace still pass isTypedFilter against this module.
interface TypedFilter<T extends TypedUpdate = TypedUpdate> {
readonly [FILTER_BRAND]: true;
readonly predicate: (update: TypedUpdate) => update is T;
readonly describe: () => string;
}predicateis a synchronous TypeScript type-guard that narrowsTypedUpdatetoT.describeis a debug / logging label. Combinators compose a deterministic label from their children (e.g."and(message, message.textMatches(/hello/i))").
createTypedFilter(predicate, describe)
Wraps a bespoke type-guard predicate and a describe function into a TypedFilter<T>. The predicate is NEVER invoked during construction — only its shape is validated.
const fromAlice = createTypedFilter<TypedMessageUpdate>(
(u): u is TypedMessageUpdate =>
u.kind === "message" && u.message.from === "alice",
() => "from=alice"
);Rejects a non-function predicate with FilterValidationError("invalid_predicate") and a non-function describe with FilterValidationError("invalid_describe").
isTypedFilter(value): value is TypedFilter
Brand check. Safe to call on unknown. Returns false for plain objects that happen to shape like a filter but lack the FILTER_BRAND symbol.
FILTER_BRAND
Exported unique symbol, interned via Symbol.for, so a filter produced in a consumer package still identifies correctly.
Brand-forgery caveat
Global interning is how cross-workspace filter identity works. The flip side: any code running in the same realm can mint an object carrying the same symbol key, and it will pass isTypedFilter. The brand is an ergonomic contract, not a security boundary — it guards against honest structural matches from untyped code, not hostile code in your process. If plugins supply filters across a trust boundary, enforce that boundary at the loader (module allow-list, signed manifests, worker isolation), not via the brand.
Kind filters
A kind filter narrows a TypedUpdate to one of the normalized update variants. Each is a TypedFilter by itself AND a base for and(...) / or(...) composition.
message— matchesTypedMessageUpdateonly.status— matchesTypedStatusUpdateonly.account— matchesTypedAccountUpdateonly.template— template account-event namespace and kind-like filter. MatchesTypedAccountUpdatevalues whose normalizer output carriesupdate.template.call— calling namespace and kind-like filter. MatchescallConnect,callTerminate, andcallStatusupdates, with helpers for connect/terminate/status/ringing/answered/rejected/incoming/outgoing.group— Groups namespace and kind-like filter. Matches normalized group messages, group status receipts, and the four group webhook update kinds, withgroup.message(),group.participantsUpdate(),group.lifecycleUpdate(),group.settingsUpdate(),group.statusUpdate(), andgroup.fromGroup(groupId)helpers.userPreferences— namespace and kind filter for normalizeduser_preferenceswebhook rows, withuserPreferences.preference(value)anduserPreferences.category(value)helpers.system— namespace and kind filter for normalizedsystemwebhook rows, withsystem.phoneNumberChange()andsystem.identityChange()helpers.chatOpened— namespace and kind filter for normalizedchat_openedhooks, withchatOpened.requestWelcome()forREQUEST_WELCOME.unknown— matchesTypedUnknownUpdateonly (re-exported asunknownon the barrel; rename on import if it collides).
if (message.predicate(update)) {
// update is narrowed to TypedMessageUpdate here
console.log(update.message.id);
}message, status, and template are also namespaces carrying their built-in factories. See below.
Sibling-kind safety
Every kind filter and every built-in returns false without throwing when given an off-kind update. message.textMatches(/foo/).predicate(statusUpdate) is safe and returns false — the outer kind guard runs before any body-specific inspection.
Calling filters
call is a branded filter namespace for typed calling updates emitted by normalizeWebhookEnvelope(...) from field: "calls" payloads.
import { call, and } from "@wats/core/filtersTyped";
const answeredIncoming = and(call.answered(), call.incoming());
if (call.connect().predicate(update)) {
console.log(update.call.id, update.call.direction);
}Helpers:
call— anycallConnect,callTerminate, orcallStatusupdate.call.connect()—kind === "callConnect".call.terminate()—kind === "callTerminate".call.status()— anykind === "callStatus".call.ringing()/call.answered()/call.rejected()— call status valuesRINGING,ACCEPTED, andREJECTED.call.incoming()/call.outgoing()— connect/terminate updates whose direction isUSER_INITIATEDorBUSINESS_INITIATED.
Sibling-kind safety applies: every calling helper returns false, not a thrown property-access error, for message/status/account/template/unknown updates.
Groups filters
group is a branded filter namespace for normalized Groups updates. All helpers are credential-free and operate on normalizeWebhookEnvelope(...) output.
import { and, group } from "@wats/core/filtersTyped";
wa.on(and(group.message(), group.fromGroup("120363...")), async (ctx) => {
console.log(ctx.update.message.groupId);
});Helpers:
group— any group message, group status receipt, orgroup_*_updatewebhook.group.message()—kind === "message"with normalizedmessage.groupId.group.participantsUpdate()—kind === "groupParticipants".group.lifecycleUpdate()—kind === "groupLifecycle".group.settingsUpdate()—kind === "groupSettings".group.statusUpdate()—kind === "groupStatus"or a status receipt whose normalizedrecipientTypeis"group".group.fromGroup(groupId)— narrows any group-bearing update to one group id.
group.fromGroup(groupId) rejects non-string or empty/whitespace-only ids with FilterValidationError; predicates return false for non-group siblings. The root filtersTyped.group namespace mirrors the @wats/core/filtersTyped subpath.
User-preferences, system, and chat-opened filters
import { chatOpened, system, userPreferences } from "@wats/core/filtersTyped";
if (userPreferences.preference("opt_out").predicate(update)) {
console.log(update.preference.waId, update.preference.category);
}
if (system.phoneNumberChange().predicate(update)) {
console.log(update.system.phoneNumberChange.newPhoneNumber);
}
if (chatOpened.requestWelcome().predicate(update)) {
console.log(update.chatOpened.from);
}Helpers:
userPreferences— any normalizeduser_preferencesrow.userPreferences.preference("opt_in" | "opt_out")— exact preference match.userPreferences.category(category)— exact category match.system— any normalized system phone/identity event.system.phoneNumberChange()—system.type === "phoneNumberChange".system.identityChange()—system.type === "identityChange".chatOpened— any normalizedchat_openedevent.chatOpened.requestWelcome()—chatOpened.type === "REQUEST_WELCOME".
All of these are sibling-kind safe: off-kind updates return false.
Combinators
All combinators short-circuit and return new TypedFilter instances. They validate their inputs at construction time via isTypedFilter and the FilterValidationError codes below. They do not try / catch — a consumer-supplied custom() predicate that throws will propagate its error to the caller unchanged.
and(...filters)
and(message, message.textMatches(/hello/i));- Zero args →
FilterValidationError("empty_args"). - Any non-
TypedFilterargument →FilterValidationError("not_a_filter"). - Returns a branded
TypedFilter<T>whosedescribe()is"and(<child1>, <child2>, ...)".
or(...filters)
or(status.sent(), status.delivered());Same validation contract as and. Matches if any child matches.
not(filter)
not(message);Inverts a filter. Negating a narrowing is no longer a narrowing — the result is typed as TypedFilter<TypedUpdate>.
Rejects a non-filter arg with FilterValidationError("not_a_filter").
custom(predicate, describe?)
custom<TypedMessageUpdate>(
(u): u is TypedMessageUpdate =>
u.kind === "message" && u.message.from === "15551234567",
"from=15551234567"
);Wraps a user-supplied type-guard. Synchronous by contract — if the predicate throws, the throw propagates. Rejects a non-function predicate with FilterValidationError("invalid_predicate").
Message built-ins (message.*)
Message built-ins target TypedMessageUpdate. Each checks u.kind === "message" first; an off-kind update returns false immediately (never throws). Beyond the text/type/from set there are media, location, reaction, interactive-reply, and quick-reply button helpers over the deep-normalized message body families emitted by normalizeWebhookEnvelope.
message.text(substring?)
- Matches any text message (
u.message.type === "text"andu.message.text.bodyis a string) when called without a substring. - With a substring, matches when the body contains the substring (case-sensitive).
message.text("")rejected at construction withFilterValidationError("empty_substring"). Non-string substring rejected withinvalid_predicate.
const hasHello = message.text("hello");message.textMatches(pattern)
- Accepts a
RegExpor a string pattern. Strings are compiled vianew RegExp(pattern)inside a try/catch; unparseable patterns throwFilterValidationError("invalid_pattern"). - Matches when the text body matches the pattern.
- RegExp flag handling. The factory clones the supplied regex at construction time and strips the
g(global) andy(sticky) flags on the clone. These two flags makeRegExp.prototype.testmutatelastIndex, which would leak state across successive predicate calls and break filter purity (returning alternatingtrue/falseon identical input). All other flags (i,m,s,u,v,d) are preserved. The caller-owned regex is never mutated — itslastIndexremains untouched. Implementation:new RegExp(regex.source, regex.flags.replace(/[gy]/g, "")).
const startsWithHi = message.textMatches(/^hi\b/i);
// /g and /y are stripped on the internal clone; the predicate is
// stateless and the caller-owned regex is untouched:
const r = /hello/g;
const f = message.textMatches(r);
f.predicate(update); // true
f.predicate(update); // true (not alternating)
r.lastIndex; // still 0message.textEquals(value)
- Exact (case-sensitive) body equality. Non-string
valuerejected.
message.type(messageType)
- Narrows on the inner
.typediscriminator ofWhatsAppMessage. Pass a closed literal (e.g."image","button","interactive"). - Empty-string / non-string rejected.
message.from(phoneNumber)
- Matches when
u.message.from === phoneNumber— strict string equality. No E.164 normalization is performed: the filter does not add or strip+, leading zeros, country-code prefixes, or whitespace. Normalize both sides before construction (e.g., vialibphonenumber-js) or compose withcustom(...)for richer matching. - Empty / non-string rejected.
Media, location, reaction, interactive, and button helpers
message.media()— matches any normalized media message body: image, video, audio, document, or sticker.message.image()/video()/audio()/document()/sticker()— match the corresponding normalized media subtype.message.location()— matches normalized location messages.message.reaction(emoji?)— matches reaction messages, optionally by exact emoji. Empty / whitespace-only / non-string exact values throwFilterValidationError.message.reactionAdded()— matches reaction messages whose normalizedreaction.emojiis non-empty.message.reactionRemoved()— matches reaction messages whose normalizedreaction.emojiis the empty string.message.interactive()— matches normalized interactive replies.message.interactiveButtonReply(id?)— matchesinteractive.type === "button_reply", optionally by exact reply id.message.interactiveListReply(id?)— matchesinteractive.type === "list_reply", optionally by exact row id.message.interactiveNfmReply()— matchesinteractive.type === "nfm_reply"Flow-completion replies.message.button(payload?)— matches quick-replytype: "button"messages, optionally by exact payload.
All of these use normalized camelCase message fields and do not inspect rawChange; malformed same-kind bodies return false rather than throwing.
Not implemented yet: command parsing, MIME/extension filters, location radius filters, generic callback-data factories.
Status built-ins (status.*)
The status built-ins target TypedStatusUpdate and match against the closed WhatsAppMessageStatusKind discriminator. Each returns false (never throws) for off-kind updates.
status.sent()— matchesstatus.status === "sent".status.delivered()— matches"delivered".status.read()— matches"read".status.played()— matches"played"voice playback receipts.status.failed()— matches"failed".
Not implemented yet: status.deleted() (pending a status-wire audit) and per-error-code matchers over TypedStatusUpdate.status.errors[].
Template account-event built-ins (template.*)
The template export is a branded TypedFilter<TypedAccountUpdate> and a namespace for template webhook filters. It matches account updates where normalizeWebhookEnvelope populated update.template from a template status/quality/category/components payload.
template.status()— matchesmessage_template_status_updateaccount updates with normalized template fields.template.status("APPROVED")— additionally checks the status event string.template.name(value)/template.id(value)/template.language(value)— match normalized template helper fields.
All template filters are sibling-kind safe: message/status/unknown updates and non-template account updates return false without throwing. Factory arguments must be non-empty strings; malformed values throw FilterValidationError at construction.
Error taxonomy
FilterValidationError extends Error is thrown only at construction time (factory invocation). Its stable .code field is one of:
empty_args—and()/or()called with zero filters.not_a_filter—and/or/notreceived an argument that does not passisTypedFilter.invalid_pattern—message.textMatches(pattern)received a non-string / non-RegExp pattern, or a string pattern thatnew RegExp(...)could not parse.invalid_predicate—createTypedFilter/custom/ the built-ins received a non-function predicate or the wrong-typed scalar.empty_substring—message.text('')/message.type('')/message.from('')called with an empty string.invalid_describe—createTypedFilterreceived a non-functiondescribe.
Evaluation-time exception policy
Filter predicates do NOT swallow exceptions. If a consumer-supplied custom(fn) predicate throws, the throw propagates unchanged to the caller of filter.predicate(update) and through any enclosing and/or/not. The router owns the final try/catch boundary — filters are pure functions and never rethrow, wrap, or log. This keeps the surface composable and avoids hiding programmer error behind silent false returns.
Type narrowing guarantee
TypedFilter<T>.predicate is a TypeScript type guard (update is T). Applied to a TypedUpdate, it narrows the variable inside the truthy branch:
function handle(update: TypedUpdate) {
if (message.predicate(update)) {
// update: TypedMessageUpdate
// update.message is the inner WhatsAppMessage
} else if (status.predicate(update)) {
// update: TypedStatusUpdate
}
}and(a, b) returns TypedFilter<A & B>; or(a, b) returns TypedFilter<A | B>; not(a) returns TypedFilter<TypedUpdate> (negation erases the narrowing).
Full usage example
import { normalizeWebhookEnvelope } from "@wats/core";
import {
and,
custom,
message,
status
} from "@wats/core/filtersTyped";
import type { TypedMessageUpdate } from "@wats/core";
const helloFromAlice = and(
message,
message.textMatches(/hello/i),
custom<TypedMessageUpdate>(
(u): u is TypedMessageUpdate =>
u.kind === "message" && u.message.from === "alice",
"from=alice"
)
);
const deliveredStatuses = status.delivered();
const result = normalizeWebhookEnvelope(envelope);
for (const update of result.updates) {
if (helloFromAlice.predicate(update)) {
// update: TypedMessageUpdate — narrowed, `update.message.from === "alice"`
} else if (deliveredStatuses.predicate(update)) {
// update: TypedStatusUpdate — narrowed
}
}Related
normalizeWebhookEnvelope— producer of theTypedUpdateunion these filters consume.- Public API surface