Skip to content

b2m9/anyall

Repository files navigation

anyall

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+

Why grouping matters

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.

Install

npm install @b2m9/anyall

Quick start

Declare 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.

Expression model

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.

Choose an entry point

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

Field registry and normalization

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" }); // true

Field-definition objects accept only get and normalize as string keys. Unknown string keys are configuration errors rather than ignored metadata.

Saved views

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.

API reference

createFilter<RecordType>()(fields)

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, options?)

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.

filter.parse(jsonOrValue)

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.

filter.compile(expression)

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.

filter.filter(records, expression)

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.

Results, errors, and types

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.

Operators and empty values

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.

Rows and grouping

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.

Operational guardrails

  • 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 AnyallConfigError rather than becoming a matchable value.
  • V1 is text-only. Numbers use JavaScript's String representation, so contains "4" matches 42; 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.

Deliberate limits

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.

License

MIT © 2026 Bob Massarczyk

Releases

Used by

Contributors

Languages