Skip to content

Filters

A filter applies as value | name or value | name(args). Filter arguments are themselves expressions, evaluated before the filter applies, so default(inputs.fallback) is as valid as default(0). Two filters are excepted: the arguments of each and keep are evaluated per element.

The core filter set is normative and closed within a spec version. Implementations MUST provide every filter in it. Implementations MUST NOT add a filter with a bare name.

That annex defines filter behavior, and not any implementation’s host language. It states every case where implementations are known to diverge: rounding at a midpoint, the directives of format_time, word boundaries in title, and the unit scale of human_size.

Selection and projection. The language has no lambda. A field predicate takes a path, an operator name from a closed set, and a value. A computed predicate and a projection each take one bounded expression, evaluated per element with current bound. Neither accepts an arbitrary body: no statements, no new names beyond current, and no nesting.

The op argument of where MUST be a string literal from the set ==, !=, <, <=, >, >=, in, not_in. It is never a computed value, so a validator can check the comparison. These are comparison names, not expression operators: not_in denotes the negation of in. An element whose path is null satisfies != and not_in only.

keep is the computed counterpart of where. Its argument is evaluated once per element, with current bound to the element. The argument MUST be boolean-typed.

{{ tickets | keep(current.priority == 'high' or (current.age_days | default(0)) > 7) }}

each is the projection filter. Within its argument, current names the element under evaluation, and enclosing bindings remain visible.

{{ event.attendees | each(current.email | split('@') | last) | unique }}

current resolves inside the argument of each or keep, and nowhere else. The argument of either filter is one expression, subject to purity. It MUST NOT contain an each or a keep, because per-element evaluation does not nest. Evaluation order follows the source list.

Implementations SHOULD warn when the argument of a keep is a single field comparison that where can express, because where is checkable without evaluation.

The core set is closed, and the filter namespace is not. Extension filters come from filter packages, and are always namespaced with a dot:

{{ message.subject | text.slugify }}
{{ order.total | money.to_currency("USD") }}

A bare name is always core, and a dotted name is always an extension. A core filter that a later spec version adds therefore cannot collide with an extension name.

A filter package declares its filters in a manifest:

manifest: 1
kind: filters
namespace: text
version: 1.2.0
filters:
slugify:
input: "string"
args: []
output: "string"
fixtures: fixtures/slugify.jsonl
wrap:
input: "string"
args: [{ name: width, type: "int", default: 80 }]
output: "string"
fixtures: fixtures/wrap.jsonl

An extension filter MUST satisfy the same contract as a core filter: pure, deterministic, and total, with no I/O. Its signature MUST be declared, and implementations MUST validate a call against it exactly as they validate connector arguments.

A package MUST ship input and output fixtures for every filter. Implementations MUST reject a package that does not. Implementations MUST resolve and version-pin filter packages when a run starts, as they do for connectors. An implementation that lacks a referenced package MUST reject the Workfile, and MUST NOT substitute another version.

This standard defines the manifest format and the contract. The standard library defines the canonical packages, and every other package is published independently.