npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

@plumile/filter-query

v0.1.38

Published

Typed filter query string parser and serializer for Plumile ecosystem

Downloads

277

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-query

Module 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 the types field.

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(), rejects NaN / Infinity.
  • String field: raw decoded string (empty string allowed, but not produced by numeric parse).
  • Lists: split by comma; invalid members emit InvalidValue and are skipped; empty result discards whole operator.
  • Between: must have exactly 2 comma-separated values; otherwise InvalidArity.
  • Duplicate between: first valid stored, later ones produce DuplicateBetween.

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%20bar

Using 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,nin
  • stringField() 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 field

Reference 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

  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).
  6. Implicit equality: when an eq value exists it is serialized as field=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: UnknownOperator

Performance 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