@plumile/filter-query
v0.1.38
Published
Typed filter query string parser and serializer for Plumile ecosystem
Downloads
277
Maintainers
Readme
@plumile/filter-query
Typed, schema-driven filter query string parser / serializer with immutable helpers and strong TypeScript inference. Zero runtime dependencies.
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/lib/esm/*: deep imports for specific helpers when needed.@plumile/filter-query/lib/types/*: TypeScript declarations consumed automatically by thetypesfield.
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).
- 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 {
defineSchema,
numberField,
stringField,
parse,
stringify,
setFilter,
} from '@plumile/filter-query';
const schema = defineSchema({
price: numberField(),
title: stringField(),
});
// 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, schema, '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 Plumile 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
});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,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.
Mutation Helpers
setFilter(filters, schema, 'price', 'gt', 10); // add/update value
setFilter(filters, schema, '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? To keep type inference precise and predictable. Add new core operators via a PR if they 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).
License
MIT
