Package Exports
- @nanotiny/json-expression
Readme
@nanotiny/json-expression
Query JSON data with compact string expressions. Filter collections, transform values, evaluate conditions, and reuse variables across queries without adding dependencies.
Beta: This package is still in beta. Expect the API and query behavior to evolve, and be aware that breaking changes may happen between releases until the project reaches a stable version.
Install
npm install @nanotiny/json-expressionHighlights
- Zero runtime dependencies
- Works in Node.js and modern browsers
- Supports filtering, transforms, and boolean evaluation
- Includes shared query context for variables and aliases
- Ships with TypeScript declarations, ESM, and CommonJS builds
Quick Start
import {
QueryContext,
evaluate,
query,
queryAsArray,
queryBatch,
} from '@nanotiny/json-expression';
const data = {
version: '2.0',
status: 'active',
companies: [
{ id: 'company1', name: 'Acme Corporation', type: 'corporation', revenue: 15000000 },
{ id: 'company2', name: 'Tech Solutions Ltd', type: 'limited', revenue: 8000000 },
],
};
query(data, 'version');
// '2.0'
query(data, 'companies.name | id == "company2"');
// 'Tech Solutions Ltd'
query(data, 'companies.name | id == "company1" \\ toUpper()');
// 'ACME CORPORATION'
queryAsArray(data, 'companies');
// [{ id: 'company1', ... }, { id: 'company2', ... }]
evaluate(data, 'status == "active"');
// true
const ctx = new QueryContext();
queryBatch(data, ctx, '$company = "company1"', 'companies.name | id == $company');
// ['', 'Acme Corporation']Query Syntax
property.path | filter_condition \ transform()Each part is optional after the property path:
- Property path: navigate the JSON structure with
. - Filter: narrow array items with
| - Transform: modify the result with
\
When writing queries in JavaScript or TypeScript strings, escape the transform delimiter as \\.
Property Access
version
companies.name
metadata.settings.enableLoggingProperty access resolves nested values across both objects and arrays.
Core rule:
query()always returns a single resolved value.- If navigation reaches multiple candidates,
query()returns the first matched result. - This is true whether the match comes from plain property navigation or from a filter.
- Use
queryAsArray()when you want every match instead of the first one.
Examples based on the test suite:
query(data, 'version');
// '2.0'
query(data, 'metadata.settings.enableLogging');
// true
query(data, 'companies.name');
// 'Acme Corporation' // first company name
query(data, 'companies.name | id == "company2"');
// 'Tech Solutions Ltd' // first company that matches the filter
query(data, 'companies.locations.city | locations.$index == 1');
// 'Los Angeles' // first city produced by the matched path
queryAsArray(data, 'companies');
// returns all companiesFilters
Use filters after | to select matching array items.
Comparison Operators
| Operator | Example |
|---|---|
== |
companies.name | id == "company1" |
!= |
companies.name | type != "limited" |
> < >= <= |
companies.name | revenue > 10000000 |
in |
companies.name | id in ["company1", "company3"] |
not in |
companies.name | id not in ["company2"] |
like |
companies.name | name like "%Corp%" |
not like |
companies.name | name not like "%Ltd" |
contains |
companies.name | name contains "Solutions" |
startswith |
companies.name | name startswith "Tech" |
endswith |
companies.name | name endswith "Ltd" |
is null |
companies.name | description is null |
is not null |
companies.name | description is not null |
between |
companies.name | revenue between 8000000 and 16000000 |
Combine Conditions
Note:
&&and||inside a filter expression are not currently parsed as separate conditions. The operator and everything after it becomes part of the right-hand value, so the compound filter will never match. Use two separatequery()calls and combine the results in JavaScript instead.
// ✗ Does not work — && is not parsed as a logical operator in filters
query(data, 'employees.name | department == "Engineering" && isRemote == false');
// ✓ Workaround — two queries, one JS condition
const name = query(data, 'employees.name | department == "Engineering"');
const remote = query(data, 'employees.isRemote | department == "Engineering"');
if (!remote) { /* John Doe */ }Filter by Position with $index
The library injects $index while iterating arrays.
companies.name | $index == 1
companies.locations.city | locations.$index == 1Cross-Reference Another Query
Use parentheses to resolve another query and compare against its result.
companies.name | id == (employees.companyId | id == "emp1")Variables and Aliases
For reusable queries, create a QueryContext and pass it to query() or queryBatch().
Variables
Assign a value once and reference it later with a $ prefix.
import { QueryContext, query } from '@nanotiny/json-expression';
const ctx = new QueryContext();
query(data, '$companyId = "company1"', ctx);
query(data, 'companies.name | id == $companyId', ctx);
// 'Acme Corporation'Notes:
- Assignments return an empty string because they update the context instead of selecting data.
- Variables work well with
queryBatch()when several queries need shared state.
Aliases
Aliases shorten long paths and make complex expressions easier to read.
import { QueryContext, query } from '@nanotiny/json-expression';
const ctx = new QueryContext();
query(data, 'companies as comp | comp.id == "company2"', ctx);
query(data, 'comp.name', ctx);
// 'Tech Solutions Ltd'Use aliases when the same root path appears repeatedly in filters or transforms.
Transforms
Apply transforms after \. In code, write them as \\ inside strings.
String Transforms
| Transform | Example | Result |
|---|---|---|
toUpper() |
name \ toUpper() |
"ACME" |
toLower() |
name \ toLower() |
"acme" |
substring(start, length) |
name \ substring(0, 4) |
"Acme" |
split(delimiter) |
name \ split(" ") |
["Acme", "Corporation"] |
replace(old, new) |
name \ replace("Corp", "Co") |
"Acme Co" |
trim() |
desc \ trim() |
trimmed string |
padLeft(width, char) |
id \ padLeft(12, "0") |
"0000company1" |
padRight(width, char) |
id \ padRight(12, ".") |
"company1...." |
Numeric Transforms
| Transform | Example | Result |
|---|---|---|
round(decimals) |
price \ round(1) |
3128431.2 |
ceiling() |
price \ ceiling() |
3128432 |
floor() |
price \ floor() |
3128431 |
abs() |
value \ abs() |
absolute value |
toNumFormat(format) |
version \ toNumFormat("00.0") |
"02.0" |
Array Transforms
| Transform | Example | Description |
|---|---|---|
count() |
companies \ count() |
Number of elements |
min() |
values \ min() |
Minimum numeric value |
max() |
values \ max() |
Maximum numeric value |
sum() |
values \ sum() |
Sum of numeric values |
average() |
values \ average() |
Average of numeric values |
slice(start, length) |
companies \ slice(0, 2) |
Sub-array |
sort(prop, dir) |
companies \ sort("name", "asc") |
Sorted copy |
Conversion and Conditional Transforms
| Transform | Example | Result |
|---|---|---|
toRoman() |
count \ toRoman() |
"III" |
numToText() |
count \ numToText() |
"Three" |
toDateFormat(fmt) |
createdAt \ toDateFormat(yyyy-MM-dd) |
"2024-04-16" |
if(cond, true, false) |
price \ if("> 3000000", "expensive", "affordable") |
"expensive" |
ifBlank(default) |
desc \ ifBlank("N/A") |
"N/A" if blank |
Chain Transforms
query(data, 'companies.name | id == "company1" \\ toUpper() \\ substring(0, 4)');
// 'ACME'
query(data, 'companies \\ count() \\ toRoman()');
// 'II'API Reference
query(data, queryStr, context?)
Returns the first matching value.
query(data, 'companies.name | id == "company1"');
// 'Acme Corporation'Use context when you need variables or aliases to persist across calls.
queryAsArray(data, queryStr)
Returns every matching value as an array.
queryAsArray(data, 'companies');
// [{ id: 'company1', ... }, { id: 'company2', ... }]evaluate(data, condition)
Evaluates a condition against the input data and returns a boolean.
evaluate(data, 'version == "2.0"');
// true
evaluate(data, 'status == "inactive"');
// falseNote: The same
&&/||parser limitation applies here. Use two separateevaluate()calls and combine with JavaScript&&/||.
evaluate(data, 'version == "2.0"') && evaluate(data, 'status == "active"');
// truequeryBatch(data, ...queries)
Executes multiple queries and returns the results in order.
queryBatch(data, 'version', 'status', 'companies.name | id == "company1"');
// ['2.0', 'active', 'Acme Corporation']queryBatch(data, context, ...queries)
Executes multiple queries with a shared QueryContext.
const ctx = new QueryContext();
queryBatch(
data,
ctx,
'$company = "company2"',
'companies.name | id == $company',
);
// ['', 'Tech Solutions Ltd']QueryContext
Stores variables and aliases across multiple queries.
const ctx = new QueryContext();
query(data, '$id = "company1"', ctx);
query(data, 'companies.name | id == $id', ctx);
// 'Acme Corporation'Edge Cases
Path Not Found
When a key does not exist anywhere in the path, query() returns undefined. Use the ifBlank() transform or the ?? operator to supply a default.
| Scenario | Returns |
|---|---|
Root key missing (query(data, 'noSuchKey')) |
undefined |
Nested key missing (query(data, 'companies.noSuchField')) |
undefined |
| Filter with no matching item | undefined |
Key exists, value is null or blank |
'' (empty string) |
query(data, 'nonExistentKey'); // undefined
query(data, 'companies.nonExistentField'); // undefined
query(data, 'companies.name | id == "ghost"'); // undefined
query(data, 'companies.description | id == "co2"'); // '' (null in data)
query(data, 'companies.description | id == "co2" \\ ifBlank("N/A")'); // 'N/A'
// Safe default in JavaScript
const value = (query(data, 'someKey') ?? 'default') as string;Invalid Filter Syntax
A filter expression that uses an unrecognised operator or is structurally malformed throws an Error at parse time.
try {
query(data, 'companies.name | id ?? "company1"'); // unknown operator ??
} catch (e) {
// Error: Invalid filter expression: id ?? "company1"
}
try {
query(data, 'companies.name | id'); // missing operator and right operand
} catch (e) {
// Error: Invalid filter expression: id
}Always validate user-supplied query strings with a try/catch when the input comes from external sources.
Type Mismatch in Comparisons
All operand values are coerced to strings before comparison. There is no runtime type error — mismatched types produce a silent non-match instead.
| Operator | Behaviour on type mismatch |
|---|---|
== / != |
String equality after coercion — 42 becomes "42", true becomes "true" |
> < >= <= |
Uses parseFloat — a non-numeric value becomes NaN and the comparison returns false |
between |
Uses parseFloat on both bounds — non-numeric silently returns false |
contains / startswith / endswith / like |
Applied to the stringified value — always safe |
// Numeric field vs unquoted number → both stringify to "120000" → matches
query(data, 'employees.name | salary == 120000'); // 'John Doe'
// Numeric field vs quoted string → quotes stripped, string-equal → still matches
query(data, 'employees.name | salary == "120000"'); // 'John Doe'
// Ordering: non-numeric value on numeric operator → NaN → no match, no error
query(data, 'employees.name | name > 100'); // undefined (silent)
// Boolean field: stored as boolean, coerced to "true"/"false" for comparison
query(data, 'companies.name | isActive == true'); // 'Acme Corporation'
query(data, 'companies.name | isActive == "true"'); // 'Acme Corporation' (quotes stripped)Compatibility
- Node.js 14 or later
- Modern browsers with ES2020 support
- ESM and CommonJS builds
- TypeScript declarations included
License
MIT