Typed filter query string parser and serializer for Plumile ecosystem
Typed, schema-driven filter query string parser / serializer with immutable helpers and strong TypeScript inference. Zero runtime dependencies.
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).
``bash`
npm install @plumile/filter-query
- @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 the types field.
The package is ESM-only; ensure your bundler supports native ESM (Vite, Next.js, webpack 5+ with type: 'module', etc.).
| Concept | Description |
| ---------------- | --------------------------------------------------------------------------------- |
| Schema | Object produced by defineSchema({ field: numberField(), ... }). Required. |numberField()
| Field Descriptor | 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). |
| 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. |eq
| Textual comparison | , neq | title.eq=foo | Works for strings too; title=foo is implicit eq. |contains
| Text search | , sw, ew | title.contains=foo%20bar | Raw values URL-decoded. |between
| Range | | price.between=10,100 | Only first valid occurrence kept; duplicates yield DuplicateBetween diagnostic. |in
| Inclusion lists | | id.in=1,2,3 | Multi-occurrences merge: id.in=1,2&id.in=3 -> [1,2,3]. |nin
| Exclusion lists | | id.nin=4,5&... | Same merging logic as in. |
- Number field: attempts Number(), rejects NaN / Infinity.true
- String field: raw decoded string (empty string allowed, but not produced by numeric parse).
- Boolean field: accepts , false, 1, 0 (case-insensitive).InvalidValue
- Lists: split by comma; invalid members emit and are skipped; empty result discards whole operator.InvalidArity
- Between: must have exactly 2 comma-separated values; otherwise .between
- Duplicate : first valid stored, later ones produce DuplicateBetween.
`ts
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, 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%20bar
`
Attach the schema to a route and rely on router hooks for strongly typed filters.
`ts
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.createRouter(..., { instrumentations: [createDevtoolsBridgeInstrumentation()] })
- Enable the Plumile Router DevTools extension by wiring an instrumentation in development: .
`ts`
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).
- 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
Returned shape:
`ts`
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.).
From a schema:
`ts`
const schema = defineSchema({ price: numberField(), title: stringField() });
The inferred filters type is roughly:
`ts`
{
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.
You can enforce custom parsing/serialisation rules via customField:
`ts
import { customField, defineSchema } from '@plumile/filter-query';
const schema = defineSchema({
since: customField
operators: ['eq'],
parse(raw) {
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) {
return undefined;
}
return parsed;
},
serialize(value) {
return value.toISOString();
},
}),
});
`
`ts`
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 field
- If a mutation results in no semantic change -> the _same_ object reference is returned.
- Enables cheap memoization (useMemo, React context selectors, etc.).
1. Field iteration order = schema object key order (stable in modern JS for own string keys).
2. Operator order = descriptor.operators order.?
3. Operators with no value / empty arrays are skipped.
4. List operators keep insertion order across multi-occurrences.
5. Output omits leading (caller decides how to prefix).eq
6. Implicit equality: when an value exists it is serialized as field=value (no .eq).
| 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 |
`ts`
const minimal = defineSchema({ price: numberField(['gt', 'lt']) });
// parse('price.eq=5', minimal) -> diagnostics: UnknownOperator
- 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).
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).
MIT