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 🙏

© 2026 – Pkg Stats / Ryan Hefner

@sirzag/filter-ast

v1.3.0

Published

Parses a declarative filter object with operator strings into a typed, database-agnostic filter tree

Readme

@sirzag/filter-ast

Turns a declarative filter object with operator strings into a database-agnostic filter tree.

Install

npm install @sirzag/filter-ast
bun add @sirzag/filter-ast

Quick start

import { buildAbstractFilterTree } from "@sirzag/filter-ast";

buildAbstractFilterTree({
  status: "$in(active,pending)",
  age: "$gte(18)",
  name: "John",
});
[
  {
    joinLogic: 'and',
    conditions: [
      { field: 'status', operator: 'in', value: [ 'active', 'pending' ] },
      { field: 'age', operator: 'gte', value: 18 },
      { field: 'name', operator: 'eq', value: 'John' }
    ]
  }
]

The package has named exports only: buildAbstractFilterTree, $and, $or, joinOr, isFilterGroup, QueryHelpers, FilterError, and the types FilterGroup, FilterCondition, FilterExpression, FilterValue, FilterFieldValue, FilterInput, LogicalOperator, Operator, ParsedOperator.

Filter syntax

Bare values

A value that is not an operator string becomes an eq condition and is passed through as is. undefined fields are dropped.

buildAbstractFilterTree({ name: "John", age: 30, deletedAt: null, archived: undefined });
[
  {
    joinLogic: 'and',
    conditions: [
      { field: 'name', operator: 'eq', value: 'John' },
      { field: 'age', operator: 'eq', value: 30 },
      { field: 'deletedAt', operator: 'eq', value: null }
    ]
  }
]

A string that starts with $ but is not a known $operator(payload) is also matched literally ("$100" → eq "$100").

Operators

An operator string has the form $operator(payload). An empty or whitespace-only payload ($eq(), $eq( )) drops the condition, except for $in and $not_in, where $in() and $in( ) are kept as an empty list. A quoted blank is a value: $eq(" ") → ' '.

| Input | Output operator | Example | Output value | |---|---|---|---| | $eq | eq | $eq(42) | 42 | | $ne | ne | $ne(42) | 42 | | $not | not | $not(42) | 42 | | $gt | gt | $gt(10) | 10 | | $gte | gte | $gte(10) | 10 | | $lt | lt | $lt(10) | 10 | | $lte | lte | $lte(10) | 10 | | $in | in | $in(1,2,3) | [ 1, 2, 3 ] | | $not_in | not_in | $not_in(a,b) | [ 'a', 'b' ] | | $contains | contains | $contains(foo) | 'foo' | | $has | has | $has(tag) | 'tag' | | $like | like | $like(%john%) | '%john%' | | $between | between | $between(1,10) | [ 1, 10 ] |

$in, $not_in and $between split the payload on ,; every other operator keeps commas as part of the value. An item that starts with " keeps its commas up to the next " not preceded by \: $in("a,b",c) → [ 'a,b', 'c' ]. Escaped quotes do not protect commas ($in(\"a,b\") → [ '\\"a', 'b\\"' ]), and an unterminated " is ordinary text ($in("a,b) → [ '"a', 'b' ]).

Value coercion

Every operator payload, and every element of $in/$not_in/$between, is coerced with one rule. This includes $like, $contains and $has. The first matching step wins:

  1. Wrapped in double quotes → the inner string, verbatim. \"...\" keeps the quotes.
  2. Starts with 0x, 0b or 0o (any case) → the string, untouched.
  3. Starts with a digit, or with - followed by a digit, and is -?digits followed by n → bigint.
  4. Starts with a digit, or with - followed by a digit, and is a complete, finite decimal number → number. Integers outside Number.MAX_SAFE_INTEGER keep their original text.
  5. true, false, null → the literal. undefined is not a literal and stays a string.
  6. Anything else → the trimmed string.

There is no Date coercion. Date-like text stays a string.

| Payload | Value | |---|---| | $eq(007) | 7 | | $eq("007") | '007' | | $like(007) | 7 | | $like("007") | '007' | | $eq(1e3) | 1000 | | $eq(0x10) | '0x10' | | $eq(0b101) | '0b101' | | $eq(0o17) | '0o17' | | $eq(true) | true | | $eq("true") | 'true' | | $eq(null) | null | | $eq(undefined) | 'undefined' | | $eq(\"quoted\") | '"quoted"' | | $eq(Infinity) | 'Infinity' | | $eq(-Infinity) | '-Infinity' | | $eq(NaN) | 'NaN' | | $eq(+1) | '+1' | | $eq(.5) | '.5' | | $eq(-.5) | '-.5' | | $eq(9007199254740991) | 9007199254740991 | | $eq(9007199254740993) | '9007199254740993' | | $eq(123n) | 123n | | $eq(9007199254740993n) | 9007199254740993n | | $eq("123n") | '123n' | | $gt(2024-01-01) | '2024-01-01' | | $gte(2024-01-01T10:20:30Z) | '2024-01-01T10:20:30Z' | | $in(1,"2",true,null) | [ 1, '2', true, null ] |

Quote text that looks like a number

Leading-zero text such as phone numbers or ZIP codes is coerced to a number unless quoted:

| Payload | Value | |---|---| | $eq(0501234567) | 501234567 | | $eq("0501234567") | '0501234567' |

Text ending in digits plus n (123n) must also be quoted to stay a string.

Hex, binary and octal stay strings

Radix-prefixed text is never converted, so crypto addresses and hex-encoded values reach the database as written:

| Payload | Value | |---|---| | $eq(0x742d35Cc6634C0532925a3b844Bc454e4438f44e) | '0x742d35Cc6634C0532925a3b844Bc454e4438f44e' | | $eq(0xdeadbeef) | '0xdeadbeef' | | $in(0x1,0b1,0o1,1) | [ '0x1', '0b1', '0o1', 1 ] |

Convert the value yourself when you need the number: Number(value) or BigInt(value).

$between on these strings compares text, not magnitude: $between(0xf,0x10) throws because '0xf' > '0x10'. Use equal-length, zero-padded values or convert them first.

Big integers

  • Use the n suffix for integers beyond Number.MAX_SAFE_INTEGER: $eq(9007199254740993n) → 9007199254740993n.
  • Without the suffix they stay strings: $eq(9007199254740993) → '9007199254740993'.
  • bigint values are not JSON-serializable. JSON.stringify throws a TypeError on a tree that contains one, so serialize them yourself.

$between

$between takes exactly two values of the same type, and the first must be less than or equal to the second.

| Payload | Result | |---|---| | $between(1,10) | [ 1, 10 ] | | $between(2024-01-01,2024-12-31) | [ '2024-01-01', '2024-12-31' ] | | $between(1,9007199254740993n) | [ 1n, 9007199254740993n ] | | $between(1,9007199254740993) | throws $between operator values must be of the same type | | $between(1,abc) | throws $between operator values must be of the same type | | $between(,5) | throws $between operator requires exactly 2 values | | $between(10,1) | throws First value in $between must be less than or equal to second value | | $between(0xf,0x10) | throws First value in $between must be less than or equal to second value | | $between(1.5,10n) | throws $between operator cannot combine a bigint with a non-integer number | | $between(true,false) | throws $between operator received an unsupported value type (boolean or null) |

  • When either side is a bigint, an integer number on the other side is converted to bigint.
  • Strings are ordered with JavaScript string comparison. ISO dates in one format order correctly: $between(2024-09-01,2024-10-01) → [ '2024-09-01', '2024-10-01' ]. Mixed or non-ISO formats can throw on a valid range: $between(9/1/2024,10/1/2024) throws First value in $between must be less than or equal to second value. Date.prototype.toString() output also orders incorrectly.

Groups

$and

import { buildAbstractFilterTree, $and } from "@sirzag/filter-ast";

buildAbstractFilterTree({ name: "John", [$and]: { age: "$gte(18)", country: "UA" } });
[
  {
    joinLogic: 'and',
    conditions: [
      { field: 'name', operator: 'eq', value: 'John' },
      {
        joinLogic: 'and',
        conditions: [
          { field: 'age', operator: 'gte', value: 18 },
          { field: 'country', operator: 'eq', value: 'UA' }
        ]
      }
    ]
  }
]

$or

One object can hold several [$or] branches. The remaining fields of that object become the first branch.

import { buildAbstractFilterTree, $or } from "@sirzag/filter-ast";

buildAbstractFilterTree({
  status: "active",
  [$or]: { role: "admin" },
  [$or]: { role: "owner", verified: true },
});
[
  {
    joinLogic: 'or',
    conditions: [
      {
        joinLogic: 'and',
        conditions: [ { field: 'status', operator: 'eq', value: 'active' } ]
      },
      {
        joinLogic: 'and',
        conditions: [ { field: 'role', operator: 'eq', value: 'admin' } ]
      },
      {
        joinLogic: 'and',
        conditions: [
          { field: 'role', operator: 'eq', value: 'owner' },
          { field: 'verified', operator: 'eq', value: true }
        ]
      }
    ]
  }
]

joinOr

joinOr joins the fields of one level with OR, without creating a nested group.

import { buildAbstractFilterTree, $and, joinOr } from "@sirzag/filter-ast";

buildAbstractFilterTree({
  name: "John",
  [$and]: joinOr({ createdAt: "$lte(2020-01-04)", twoFactorEnabled: true }),
});
[
  {
    joinLogic: 'and',
    conditions: [
      { field: 'name', operator: 'eq', value: 'John' },
      {
        joinLogic: 'or',
        conditions: [
          { field: 'createdAt', operator: 'lte', value: '2020-01-04' },
          { field: 'twoFactorEnabled', operator: 'eq', value: true }
        ]
      }
    ]
  }
]

At the root, buildAbstractFilterTree(joinOr({ a: 1, b: 2 })) returns:

[
  {
    joinLogic: 'or',
    conditions: [ { field: 'a', operator: 'eq', value: 1 }, { field: 'b', operator: 'eq', value: 2 } ]
  }
]

Output shape

buildAbstractFilterTree returns FilterGroup[]: an empty array for undefined, null, or a filter with no conditions left, otherwise one root group. A group whose conditions were all dropped (undefined fields, empty payloads, empty nested groups) is dropped at every depth, so { x: 1, [$or]: { a: undefined } } matches x = 1, never everything. Any other non-object root, and any array, throws TypeError: Filter must be a non-array object.

type ParsedOperator =
  | "eq" | "ne" | "not" | "gt" | "gte" | "lt" | "lte"
  | "in" | "not_in" | "contains" | "has" | "like" | "between";

interface FilterCondition {
  field: string;
  operator: ParsedOperator;
  value: unknown;
}

interface FilterGroup {
  joinLogic: "or" | "and";
  conditions: Array<FilterCondition | FilterGroup>;
}

isFilterGroup tells groups and conditions apart:

import { buildAbstractFilterTree, $and, isFilterGroup } from "@sirzag/filter-ast";

const [root] = buildAbstractFilterTree({ a: 1, [$and]: { b: 2 } });
root.conditions.map((c) => isFilterGroup(c)); // [ false, true ]

Errors

Every $between validation error is a FilterError. It extends Error and carries the field and the raw operator string:

import { buildAbstractFilterTree, FilterError } from "@sirzag/filter-ast";

try {
  buildAbstractFilterTree({ age: "$between(30,18)" });
} catch (error) {
  if (error instanceof FilterError) {
    error.name;    // 'FilterError'
    error.field;   // 'age'
    error.value;   // '$between(30,18)'
    error.message; // 'First value in $between must be less than or equal to second value'
  }
}

A non-object root throws a plain TypeError, not a FilterError.

Performance

Cost of one buildAbstractFilterTree call, including allocation of the returned tree. Median of 11 rounds × 100 000 calls; each cell runs in its own process after a 20 000-call warm-up. Hot reuses one filter object. Fresh cycles through 4 096 distinct pre-built objects: construction is excluded, but every call sees a new object and new $and/$or symbols. Apple M4.

Bun 1.4.2

| Workload | Hot | Fresh | |---|---|---| | flat, 3 fields | 37.4 ns (26.8 M ops/s) | 37.5 ns (26.7 M ops/s) | | operators, 5 fields | 165 ns (6.1 M ops/s) | 167 ns (6.0 M ops/s) | | nested $and/$or, 3 levels | 200 ns (5.0 M ops/s) | 392 ns (2.6 M ops/s) | | $in, 200 items | 1984 ns (504 K ops/s) | 2090 ns (478 K ops/s) |

Node v24.10.0

| Workload | Hot | Fresh | |---|---|---| | flat, 3 fields | 61.2 ns (16.3 M ops/s) | 58.9 ns (17.0 M ops/s) | | operators, 5 fields | 244 ns (4.1 M ops/s) | 232 ns (4.3 M ops/s) | | nested $and/$or, 3 levels | 454 ns (2.2 M ops/s) | 455 ns (2.2 M ops/s) | | $in, 200 items | 2713 ns (369 K ops/s) | 3304 ns (303 K ops/s) |

Reproduce with bun run bench: builds dist, prints the table on Bun, then on Node. Workloads are defined in bench/bench.mjs.

Limitations

  • $and and $or work only as computed keys in object literals. They are not reusable symbols: each use creates a new key, so obj[$and] = { a: 1 } followed by obj[$and] returns undefined.
  • Any symbol key whose description is "or" is treated as an OR branch. { a: 1, [Symbol("or")]: { b: 2 } } builds an OR of a and b.
  • Input types are compile-time only. FilterInput rejects functions, symbols and plain objects as field values, but field values are not checked at runtime.
  • There are no adapters and no SQL generation. Field names are passed through unsanitized ({ "name; DROP TABLE users": "x" } → field: 'name; DROP TABLE users'). Never concatenate them into SQL.

License

MIT