@plumile/filter-query
v0.2.13
Published
Typed filter query string parser and serializer for Kronex ecosystem
Maintainers
Readme
@plumile/filter-query
Typed, schema-driven filter query string parser / serializer with immutable helpers and strong TypeScript inference. Zero runtime dependencies.
Status
Recommended for external use. This package is intended to work as a standalone
URL filter/query helper and as the filter model behind @plumile/router.
Why?
Applications often encode complex filter state (numbers, ranges, multi-selects, text searches) into a URL query string. Ad-hoc solutions easily drift: inconsistent operator names, ambiguous serialization, lost type safety, brittle parsing, and noisy re-renders. @plumile/filter-query gives you:
- A mandatory schema (single source of truth) declaring fields and allowed operators.
- Deterministic, canonical string generation (stable ordering for cache keys & SSR).
- Strong TypeScript inference for the shape of
filters(no manual typings). - Non-blocking diagnostics instead of exceptions (you decide how to surface issues).
- Immutable, reference-stable mutation helpers (minimize React renders / memo churn).
- Simple, explicit operator semantics (predictable merging & precedence rules).
Installation
npm install @plumile/filter-queryModule Entry Points
@plumile/filter-query: ESM bundle exporting parsing, serialization, and helpers.@plumile/filter-query/parse.js,@plumile/filter-query/stringify.js, and the other subpaths declared inexports: public entry points for focused imports.
TypeScript declarations are selected automatically by the package exports map.
The package is ESM-only; ensure your bundler supports native ESM (Vite, Next.js, webpack 5+ with type: 'module', etc.).
Core Concepts
| Concept | Description |
| ---------------- | --------------------------------------------------------------------------------- |
| Schema | Object produced by defineSchema({ field: numberField(), ... }). Required. |
| Field Descriptor | numberField() or stringField() optionally with a custom operator whitelist. |
| Filters Object | Parsed result shape inferred from the schema (operators become optional keys). |
| Diagnostics | Array of non-blocking issues (unknown field/op, invalid value, etc.). |
| Mutation Helpers | Pure functions returning new filter objects (or the same reference if no change). |
Supported Operators
| Category | Operators | Syntax | Notes |
| ------------------ | ------------------------------------- | -------------------------- | --------------------------------------------------------------------------------- |
| Numeric comparison | gt, gte, lt, lte, eq, neq | price.gt=10 | Last write wins per operator. price=5 is implicit eq. |
| Textual comparison | eq, neq | title.eq=foo | Works for strings too; title=foo is implicit eq. |
| Text search | contains, sw, ew | title.contains=foo%20bar | Raw values URL-decoded. |
| Range | between | price.between=10,100 | Only first valid occurrence kept; duplicates yield DuplicateBetween diagnostic. |
| Inclusion lists | in | id.in=1,2,3 | Multi-occurrences merge: id.in=1,2&id.in=3 -> [1,2,3]. |
| Exclusion lists | nin | id.nin=4,5&... | Same merging logic as in. |
Value Parsing Rules
- Number field: attempts
Number(), rejectsNaN/Infinity. - String field: raw decoded string (empty string allowed, but not produced by numeric parse).
- Boolean field: accepts
true,false,1,0(case-insensitive). - Lists: split by comma; invalid members emit
InvalidValueand are skipped; empty result discards whole operator. - Between: must have exactly 2 comma-separated values; otherwise
InvalidArity. - Duplicate
between: first valid stored, later ones produceDuplicateBetween.
Quick Start
import {
booleanField,
defineSchema,
enumField,
numberField,
stringField,
parse,
stringify,
setFilter,
} from '@plumile/filter-query';
const schema = defineSchema({
price: numberField(),
title: stringField(),
active: booleanField(),
status: enumField(['OPEN', 'CLOSED']),
});
// Parse URL search (leading '?' optional)
const { filters, diagnostics } = parse(
'price.gt=10&title.contains=foo%20bar',
schema,
);
// filters.price?.gt === 10, filters.title?.contains === 'foo bar'
// diagnostics: []
// Apply immutable mutation
const updated = setFilter(filters, 'price', 'between', [10, 100]);
// Serialize back (canonical ordering: schema field order, then operator order)
const qs = stringify(updated, schema);
// => price.gt=10&price.between=10,100&title.contains=foo%20barUsing with @plumile/router
Attach the schema to a route and rely on router hooks for strongly typed filters.
import { defineSchema, numberField, stringField } from '@plumile/filter-query';
import { r, useFilters, useNavigate } from '@plumile/router';
export const productFilters = defineSchema({
page: numberField(),
title: stringField(['contains']),
});
export const routes = [
r({
path: '/products',
querySchema: productFilters,
prepare: ({ filters }) => ({ page: filters.page?.eq ?? 1 }),
render: () => null,
}),
];
function ProductsList() {
const [filters, { set, clear }] = useFilters(productFilters);
const navigate = useNavigate();
const page = filters.page?.eq ?? 1;
const goToPage = (next: number) =>
navigate({ pathname: '/products', filters: { page: { eq: next } } });
return null;
}useFilters(productFilters)returns typed filter state and immutable helpers.useFilterDiagnostics()surfaces parsing issues (unknown field/operator) for UI or logging.- Enable the Kronex Router DevTools extension by wiring an instrumentation in development:
createRouter(..., { instrumentations: [createDevtoolsBridgeInstrumentation()] }).
Schema Definition
const schema = defineSchema({
price: numberField(), // full numeric operator set
title: stringField(['contains', 'sw']), // restrict to subset
active: booleanField(['eq', 'neq']),
status: enumField(['OPEN', 'CLOSED'], ['eq', 'in']),
});Both helpers accept an optional operator list to whitelist allowed operators (anything not listed becomes UnknownOperator if present in input).
Operator Defaults
numberField()default:gt,gte,lt,lte,eq,neq,between,in,ninstringField()default:contains,sw,ew,eq,neq,in,ninbooleanField()default:eq,neq,in,ninenumField()default:eq,neq,in,nin
Diagnostics
Returned shape:
interface DiagnosticBase {
kind: string;
field?: string;
operator?: string;
detail?: string;
}
// Kinds: 'UnknownField' | 'UnknownOperator' | 'InvalidValue' | 'InvalidArity' | 'DuplicateBetween' | 'DecodeError'Parsing never throws for content errors; all issues are accumulated. You decide how to surface them (log panel, dev overlay, UI badges, etc.).
Filters Object & Type Inference
From a schema:
const schema = defineSchema({ price: numberField(), title: stringField() });The inferred filters type is roughly:
{
price?: {
gt?: number; gte?: number; lt?: number; lte?: number; eq?: number; neq?: number;
between?: readonly [number, number];
in?: readonly number[]; nin?: readonly number[];
};
title?: {
contains?: string; sw?: string; ew?: string; eq?: string; neq?: string;
in?: readonly string[]; nin?: readonly string[];
};
}Everything is optional so you can build partial filters progressively.
TypeScript infers each field's value type and the value shape required by the
fixed operator vocabulary (scalar, tuple, or list). A descriptor's custom
operator whitelist is a runtime parsing/serialization boundary: it is not
encoded as a narrower TypeScript type. For example, numberField(['gt'])
still types price.in as readonly number[], while parsing price.in=1,2
reports UnknownOperator and discards it. This keeps descriptors assignable to
the public Schema contract without pretending that their literal whitelist is
preserved by the type system.
Custom Field Parsing
You can enforce custom parsing/serialisation rules via customField:
import { customField, defineSchema } from '@plumile/filter-query';
const schema = defineSchema({
since: customField<Date>({
operators: ['eq'],
parse(raw) {
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) {
return undefined;
}
return parsed;
},
serialize(value) {
return value.toISOString();
},
}),
});Mutation Helpers
setFilter(filters, 'price', 'gt', 10); // add/update value
setFilter(filters, 'price', 'gt', undefined); // remove operator key
removeFilter(filters, 'price'); // drop entire field
removeFilter(filters, 'price', 'gt'); // drop one operator
mergeFilters(base, patch); // shallow merge per fieldReference Stability
- If a mutation results in no semantic change -> the same object reference is returned.
- Enables cheap memoization (
useMemo, React context selectors, etc.).
Serialization Rules
- Field iteration order = schema object key order (stable in modern JS for own string keys).
- Operator order =
descriptor.operatorsorder. - Operators with no value / empty arrays are skipped.
- List operators keep insertion order across multi-occurrences.
- Output omits leading
?(caller decides how to prefix). - Implicit equality: when an
eqvalue exists it is serialized asfield=value(no.eq).
Edge Cases & Examples
| Input | Result | Diagnostics |
| ------------------------------------- | -------------------------- | ------------------------------------------- |
| price.gt=abc | filters.price?.gt absent | InvalidValue |
| price=10 | filters.price?.eq === 10 | none |
| price.between=1,2,3 | none stored | InvalidArity |
| price.between=1,5&price.between=2,6 | [1,5] | DuplicateBetween |
| price.in= | ignored | none (empty split produces no valid values) |
| unknown.gt=5 | ignored | UnknownField |
| price.xyz=5 | ignored | UnknownOperator |
| title.contains=x%ZZ | ignored | DecodeError + maybe others |
Custom Operator Subsets
const minimal = defineSchema({ price: numberField(['gt', 'lt']) });
// parse('price.eq=5', minimal) -> diagnostics: UnknownOperatorPerformance Notes
- Parsing is single pass over pairs; only allocates for: decoded strings, filter field objects when first used, diagnostics entries.
- Mutations avoid cloning unchanged branches (shallow one-level cloning only when something changes).
FAQ
Q: Why not support arbitrary operator names? The fixed operator vocabulary keeps value shapes predictable. Per-field whitelists select allowed operators at runtime; add a new core operator only when its value semantics are broadly useful.
Q: How does implicit equality work?
Supplying field=value without an explicit operator is parsed as field.eq=value internally. Serialization emits the implicit short form again for stability.
Q: How does this integrate with @plumile/router?
The router consumes the same schema (as querySchema) and builds a unified filters object accessible via its hooks. Equality remains implicit in the URL (page=2 <=> page.eq=2 internally).
Q: How do I clear everything?
Just use an empty object {} or parse an empty string and replace your state reference.
Q: Does order of repeated list operators matter? Yes, items are appended in encounter order, preserving user intent (e.g. prioritized IDs).
Validation Notes
- parsing, serialization, diagnostics, mutation helpers, and type inference are covered by unit tests and type tests in the package workspace
Limitations
- operator vocabulary is fixed; per-field operator whitelists are enforced at runtime, not narrowed in
InferFilters - schema-driven design is mandatory; arbitrary untyped filters are not the goal
- package is ESM-only
License
MIT
