Build, store, and evaluate serializable text filters over declared record fields.
@b2m9/anyall turns flat filter-builder rows into explicit any-of-all
expressions, validates saved views, and compiles them into predicates over
in-memory records.
Headless · TypeScript · ESM-only · Zero runtime dependencies · Node.js 22+
Four filter rows can hide a boolean bug:
A account contains "acme"
AND B status is not one of ["closed", "spam"]
OR C name starts with "test"
AND D plan equals "pro"
A left-to-right reducer computes (((A AND B) OR C) AND D). It wrongly rejects
an open Acme customer on the basic plan. The rows mean (A AND B) OR (C AND D):
| Record | Left to right | anyall |
|---|---|---|
| Acme North, open, basic | no | yes |
| Test Labs, closed, pro | yes | yes |
| Test Garage, closed, free | no | no |
anyall makes that grouping explicit instead of relying on reduction order.
npm install @b2m9/anyallDeclare the fields an expression is allowed to address:
import { createFilter, type FilterRow } from "@b2m9/anyall";
interface Customer {
account?: { name: string };
status?: string;
name: string;
plan: "free" | "basic" | "pro";
}
const customers = createFilter<Customer>()({
account: (customer) => customer.account?.name,
status: (customer) => customer.status,
name: (customer) => customer.name,
plan: (customer) => customer.plan,
});Convert flat rows from a filter UI, then apply the resulting expression:
const rows = [
{ field: "account", operator: "contains", value: "acme" },
{
join: "and",
field: "status",
operator: "one-of",
value: ["closed", "spam"],
not: true,
},
{ join: "or", field: "name", operator: "starts-with", value: "test" },
{ join: "and", field: "plan", operator: "equals", value: "pro" },
] satisfies readonly FilterRow[];
const built = customers.fromRows(rows);
if (!built.ok) throw new Error("The filter UI produced incomplete rows");
const records: Customer[] = [
{ account: { name: "Acme North" }, status: "open", name: "Alice", plan: "basic" },
{ account: { name: "Other" }, status: "closed", name: "Test Labs", plan: "pro" },
{ account: { name: "Other" }, status: "closed", name: "Test Garage", plan: "free" },
];
const filtered = customers.filter(records, built.expression);
filtered.map((customer) => customer.name);
// ["Alice", "Test Labs"]The double call to createFilter is deliberate. TypeScript cannot partially
infer type arguments: the first call fixes Customer, while the second infers
the literal field names from the registry.
The rows above produce versioned plain data:
{
v: 1,
any: [
{ all: [A, B] },
{ all: [C, D] },
],
}
Evaluation is exactly:
expression.any.some((group) => group.all.every((predicate) => matches(predicate)));Any group may match, while every predicate in a matching group must match. This shape is disjunctive normal form, or DNF. There is no recursive tree and no ambiguous reduction order.
Expressions require at least one group, and every group requires at least one
predicate. Empty containers are rejected rather than given vacuous meanings.
null is the explicit no-filter value.
| Starting with | Use | Result |
|---|---|---|
| Flat state from a filter UI | fromRows |
Validated expression or issues |
| Stored JSON or another unknown value | parse |
Validated expression or issues |
| An expression used repeatedly | compile |
Reusable (record) => boolean |
| Records and a one-off expression | filter |
Filtered records in original order |
Accessors make record projection visible and type-checked. A field can also define the text equivalence its domain needs. Normalization applies to both record text and the compiled search text, or needle:
const places = createFilter<{ city: string }>()({
city: {
get: (place) => place.city,
normalize: (text) => text.trim().toLowerCase().normalize("NFD").replaceAll(/\p{M}/gu, ""),
},
});
const inAarhus = places.compile({
v: 1,
any: [{ all: [{ field: "city", operator: "equals", value: "Arhus" }] }],
});
inAarhus({ city: "Århus" }); // trueField-definition objects accept only get and normalize as string keys.
Unknown string keys are configuration errors rather than ignored metadata.
Expressions need no serializer. Store them with JSON.stringify, then bring
them back through parse so renamed fields or newer wire versions become
structured issues instead of runtime surprises:
localStorage.setItem("view:customers", JSON.stringify(built.expression));
const raw = localStorage.getItem("view:customers");
if (raw === null) {
renderDefaultView(); // This key has never been saved.
} else {
const restored = customers.parse(raw);
if (!restored.ok) {
// For example, a removed `plan` field is reported at
// ["any", 1, "all", 1, "field"] with code "unknown-field".
showStaleView(restored.issues);
} else {
render(customers.filter(records, restored.expression));
}
}A missing storage key and a stored no-filter state are different. Store the
latter as JSON.stringify(null), which yields the JSON text "null";
customers.parse("null") succeeds with expression: null.
parse provides these boundary guarantees for ordinary inert data:
- JSON syntax and expression shape failures become structured issues.
- Success returns fresh plain objects without aliasing caller input.
- Raw needle text, token order, and duplicate tokens are preserved.
- Known grammar fields are reported in grammar and array order before extra keys; ordering among extra keys is not normalized.
- A well-formed unsupported wire version is reported alone because the reader cannot safely judge the rest of a foreign grammar.
Consumers should branch on issue code and path, not position or message
text.
Creates a filter bound to a declared field registry. Each field is either an
accessor or { get, normalize }; field names are inferred as string literals.
Malformed field definitions throw AnyallConfigError.
filter.fromRows(rows, { incomplete: "reject" });Segments flat UI rows, validates their predicates, and returns
ExpressionResult. Incomplete rows reject the conversion by default;
{ incomplete: "discard" } is intended for transient filter-drawer state.
Malformed options throw AnyallConfigError.
Accepts JSON text or an already-parsed value and returns ExpressionResult.
A string is always treated as JSON text. null is accepted as no filter.
Configuration failures and errors thrown by declared normalizers propagate.
Validates an expression and returns a reusable (record) => boolean predicate.
compile(null) returns a predicate that always passes. Invalid asserted input
throws AnyallExpressionError with the same issues that parse reports.
Validates and compiles the expression on every call, then filters without reordering records. It returns the input array by reference exactly when no record is excluded; otherwise it returns a fresh array. Compile once and reuse the predicate when evaluating the same expression repeatedly.
fromRows and parse share one result shape:
type ExpressionResult<Name extends string> =
| { ok: true; expression: Expression<Name> | null }
| { ok: false; issues: readonly Issue[] };Malformed configuration throws AnyallConfigError. Passing an asserted but
invalid expression to compile or filter throws AnyallExpressionError with
an issues property. Both classes are exported for instanceof checks.
Issue code and path are stable API; message wording is not.
All public types are exported: Expression, ExpressionResult, FieldDef,
Filter, FilterRow, Issue, Operator, and Predicate.
Values resolve to a list of matchable texts before an operator runs:
| Accessor result | Matchable texts |
|---|---|
| string | normalized string, unless it is empty |
| number, boolean, bigint | String(value), then normalized |
null, undefined |
none |
| array | scalar rules for each top-level element |
object, Date, function, symbol |
none |
| nested array element | none |
The default normalizer trims and lowercases. A field is empty when resolution
yields no matchable text. With the default normalizer, this includes "",
whitespace, [], and ["", null].
| Operator | Base result | Empty field |
|---|---|---|
equals |
some text equals the needle | false |
contains |
some text contains the needle | false |
starts-with |
some text starts with the needle | false |
ends-with |
some text ends with the needle | false |
one-of |
some text equals a member of the needle list | false |
empty |
there are no matchable texts | true |
not: true negates the base result. Consequently, a negated value operator is
true on an empty field, while { operator: "empty", not: true } means the
field has at least one matchable text. Empty needles are invalid.
Normalization never rewrites stored expressions. parse and fromRows invoke
it only to reject empty needles, compile captures normalized needles, and the
compiled predicate normalizes record texts. Arrays use some-semantics and are
resolved only one level deep.
fromRows segments the complete row sequence at each non-leading
join: "or", then validates rows within those groups. The first row's join is
ignored because there is no preceding group to separate. Segmentation happens
before validation so discarding an invalid row cannot pull later predicates
into an earlier group.
Incomplete rows reject the whole conversion by default. For a live filter
drawer, { incomplete: "discard" } removes invalid rows after grouping, drops
groups left empty, and returns expression: null if nothing survives. Row
values stay strict: scalar operators require a string, and one-of requires a
string array. An explicit undefined behaves like an omitted property; required
properties remain invalid when omitted. Record values are the only values
coerced to text.
- Field names and their meaning are a saved-view contract. When renaming a field, keep the old name as an alias; changing an accessor or normalizer can silently change what existing views match.
- Accessors and normalizers must be pure and total, and normalizers must return
strings. Their errors propagate; a non-string result throws
AnyallConfigErrorrather than becoming a matchable value. - V1 is text-only. Numbers use JavaScript's
Stringrepresentation, socontains "4"matches42; project a formatted string when notation matters. - Lowercasing is deterministic, not locale-aware collation. Supply a custom normalizer when your domain needs another text equivalence.
- Objects never coerce to
"[object Object]". Project a label or identifier in the accessor. - Expressions cannot introduce code or access undeclared fields, but that bounds capability, not work. Cap bytes, groups, predicates, tokens, and needle lengths before parsing untrusted input.
- Proxies, stateful getters, and similar reflective inputs can execute caller
code and are outside
parse's issues-only guarantee.
Expressions carry v: 1. A grammar change increments the wire version even
when package semver would not otherwise require a major release. A future
reader must either continue accepting every older grammar it supersets or ship
an explicit migration.
The expression language has no regex, arbitrary nesting, user-defined operators, or field-path grammar. Regex becomes a ReDoS surface when expressions are untrusted data. An expression that names executable behavior stops being portable and trustable. Recursive boolean trees are the problem JSON Logic already solves, while declared accessors avoid a path grammar and its prototype-safety policy entirely.
The package also provides no rendering, table integration, URL state, sorting, pagination, server adapter, query-language compilation, fuzzy search, numeric comparison, or date comparison.
MIT © 2026 Bob Massarczyk