wats.sh
Reference

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:

SurfaceImportShape
Legacy@wats/core/filtersuntyped 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/filters surface (UpdateFilter, the untyped and / or / not, and the hasMessageText / messageTextContains / messageFromWaId / hasMessageStatus / messageStatusIn built-ins) is now marked @deprecated at the type level and is scheduled for removal from the @wats/core barrel in the next minor release. New code should use the typed filtersTyped surface on this page; the legacy and / or / not combinators 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;
}
  • predicate is a synchronous TypeScript type-guard that narrows TypedUpdate to T.
  • describe is 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 — matches TypedMessageUpdate only.
  • status — matches TypedStatusUpdate only.
  • account — matches TypedAccountUpdate only.
  • template — template account-event namespace and kind-like filter. Matches TypedAccountUpdate values whose normalizer output carries update.template.
  • call — calling namespace and kind-like filter. Matches callConnect, callTerminate, and callStatus updates, 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, with group.message(), group.participantsUpdate(), group.lifecycleUpdate(), group.settingsUpdate(), group.statusUpdate(), and group.fromGroup(groupId) helpers.
  • userPreferences — namespace and kind filter for normalized user_preferences webhook rows, with userPreferences.preference(value) and userPreferences.category(value) helpers.
  • system — namespace and kind filter for normalized system webhook rows, with system.phoneNumberChange() and system.identityChange() helpers.
  • chatOpened — namespace and kind filter for normalized chat_opened hooks, with chatOpened.requestWelcome() for REQUEST_WELCOME.
  • unknown — matches TypedUnknownUpdate only (re-exported as unknown on 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 — any callConnect, callTerminate, or callStatus update.
  • call.connect()kind === "callConnect".
  • call.terminate()kind === "callTerminate".
  • call.status() — any kind === "callStatus".
  • call.ringing() / call.answered() / call.rejected() — call status values RINGING, ACCEPTED, and REJECTED.
  • call.incoming() / call.outgoing() — connect/terminate updates whose direction is USER_INITIATED or BUSINESS_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, or group_*_update webhook.
  • group.message()kind === "message" with normalized message.groupId.
  • group.participantsUpdate()kind === "groupParticipants".
  • group.lifecycleUpdate()kind === "groupLifecycle".
  • group.settingsUpdate()kind === "groupSettings".
  • group.statusUpdate()kind === "groupStatus" or a status receipt whose normalized recipientType is "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 normalized user_preferences row.
  • 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 normalized chat_opened event.
  • 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-TypedFilter argument → FilterValidationError("not_a_filter").
  • Returns a branded TypedFilter<T> whose describe() 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" and u.message.text.body is a string) when called without a substring.
  • With a substring, matches when the body contains the substring (case-sensitive).
  • message.text("") rejected at construction with FilterValidationError("empty_substring"). Non-string substring rejected with invalid_predicate.
const hasHello = message.text("hello");

message.textMatches(pattern)

  • Accepts a RegExp or a string pattern. Strings are compiled via new RegExp(pattern) inside a try/catch; unparseable patterns throw FilterValidationError("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) and y (sticky) flags on the clone. These two flags make RegExp.prototype.test mutate lastIndex, which would leak state across successive predicate calls and break filter purity (returning alternating true / false on identical input). All other flags (i, m, s, u, v, d) are preserved. The caller-owned regex is never mutated — its lastIndex remains 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 0

message.textEquals(value)

  • Exact (case-sensitive) body equality. Non-string value rejected.

message.type(messageType)

  • Narrows on the inner .type discriminator of WhatsAppMessage. Pass a closed literal (e.g. "image", "button", "interactive").
  • Empty-string / non-string rejected.

message.from(phoneNumber)

  • Matches when u.message.from === phoneNumberstrict 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., via libphonenumber-js) or compose with custom(...) 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 throw FilterValidationError.
  • message.reactionAdded() — matches reaction messages whose normalized reaction.emoji is non-empty.
  • message.reactionRemoved() — matches reaction messages whose normalized reaction.emoji is the empty string.
  • message.interactive() — matches normalized interactive replies.
  • message.interactiveButtonReply(id?) — matches interactive.type === "button_reply", optionally by exact reply id.
  • message.interactiveListReply(id?) — matches interactive.type === "list_reply", optionally by exact row id.
  • message.interactiveNfmReply() — matches interactive.type === "nfm_reply" Flow-completion replies.
  • message.button(payload?) — matches quick-reply type: "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() — matches status.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() — matches message_template_status_update account 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_argsand() / or() called with zero filters.
  • not_a_filterand/or/not received an argument that does not pass isTypedFilter.
  • invalid_patternmessage.textMatches(pattern) received a non-string / non-RegExp pattern, or a string pattern that new RegExp(...) could not parse.
  • invalid_predicatecreateTypedFilter / custom / the built-ins received a non-function predicate or the wrong-typed scalar.
  • empty_substringmessage.text('') / message.type('') / message.from('') called with an empty string.
  • invalid_describecreateTypedFilter received a non-function describe.

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
  }
}

On this page