@nanotiny/json-expression
v0.0.13
Published
Query JSON data with compact string expressions. Filter collections, transform values, evaluate conditions, and reuse variables across queries without adding dependencies.
Downloads
1,312
Maintainers
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
- Extensible with custom transform functions
- 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 | type == "corporation" && revenue > 10000000');
// 'Acme Corporation'
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 |
| && \|\| | companies.name \| type == "corporation" && isActive == "true" |
like and not like use SQL-style wildcard matching: % matches any sequence, _ matches a single character, and other characters are treated literally.
Combine Conditions
Use && (AND) or || (OR) to combine multiple conditions. && has higher precedence than ||, so conditions are evaluated as OR-separated groups of AND expressions.
// AND — both conditions must match
query(data, 'companies.name | type == "corporation" && isActive == "true"');
// 'Acme Corporation'
// OR — either condition can match (first matching item is returned)
query(data, 'companies.name | type == "limited" || type == "corporation"');
// 'Acme Corporation'
// Mix AND and OR (`&&` binds tighter than `||`)
query(data, 'companies.name | type == "corporation" || isActive == "true" && revenue < 1000');
// 'Acme Corporation'Explicit Boolean Grouping with []
Use [ and ] when you want to force a particular boolean grouping.
query(data, 'companies.name | [type == "limited" || type == "corporation"] && isActive == "false"');
// 'Global Innovations Corp'
query(data, 'companies.name | [id == (employees.companyId | id == "emp1") || id == "company2"]');
// 'Acme Corporation'
evaluate(data, '[version == "1.0" || status == "active"] && metadata.settings.maxRetries == 5');
// trueNotes:
- Quoted string values can include literal
&&or||(for examplename == "foo && bar"). Those sequences are only treated as boolean operators when they appear outside quoted values.- Using multiple
|delimiters in one query (e.g.path | cond1 | cond2) throws anErrorat parse time. Use&&or||inside a single filter instead.- Parentheses
()are reserved for cross-reference sub-queries, not boolean grouping.- Square brackets
[]are reserved for explicit boolean grouping.||groups must stay within a single array level inquery()filters. For example,id == "company1" || type == "limited"is fine, but[id == "company1" || locations.$index == 1]throws because it mixes company-level and location-level conditions inside one OR group.
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'Custom Transforms
You can register your own transform functions to extend the built-in set. Custom transforms work with the same \transformName(args) syntax.
Global Registration
Use registerTransform() to make a custom transform available in all queries.
import { query, registerTransform, unregisterTransform } from '@nanotiny/json-expression';
// Register a custom transform
registerTransform('reverse', (value) => {
return String(value ?? '').split('').reverse().join('');
});
query(data, 'status\\reverse()');
// 'evitca'
// Remove it when no longer needed
unregisterTransform('reverse');Context-Scoped Registration
Register transforms on a QueryContext for per-query or scoped usage.
import { query, QueryContext } from '@nanotiny/json-expression';
const ctx = new QueryContext();
ctx.registerTransform('double', (value) => {
return String(value).repeat(2);
});
query(data, 'status\\double()', ctx);
// 'activeactive'Custom Transform Function Signature
type CustomTransformFn = (
value: unknown, // current pipeline value (single element)
parameters: string[], // parsed parameters from the expression
originalArray: unknown, // the raw input before array-to-single extraction
) => unknown;The originalArray parameter gives access to the full array for aggregate transforms:
registerTransform('joinwith', (value, parameters, originalArray) => {
const separator = parameters[0] ?? ', ';
const source = Array.isArray(originalArray) ? originalArray : [value];
return source.join(separator);
});
query(data, 'metadata.tags\\joinwith(-)');
// 'production-enterprise-verified'Override Built-in Transforms
Custom transforms take priority over built-in ones with the same name. Context-level transforms take priority over global ones.
// Override built-in toupper globally
registerTransform('toupper', (value) => `CUSTOM:${String(value).toUpperCase()}`);
query(data, 'status\\toupper()');
// 'CUSTOM:ACTIVE'
// Unregister to restore built-in behavior
unregisterTransform('toupper');
query(data, 'status\\toupper()');
// 'ACTIVE'Chain Custom and Built-in Transforms
Custom transforms chain seamlessly with built-in ones.
registerTransform('exclaim', (value) => `${value}!`);
query(data, 'status\\toupper()\\exclaim()');
// 'ACTIVE!'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"');
// falseevaluate(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']registerTransform(name, fn)
Registers a custom transform function globally.
registerTransform('reverse', (value, parameters, originalArray) => {
return String(value ?? '').split('').reverse().join('');
});unregisterTransform(name)
Removes a globally registered custom transform, restoring the built-in if one exists.
unregisterTransform('reverse');QueryContext
Stores variables, aliases, and custom transforms across multiple queries.
const ctx = new QueryContext();
query(data, '$id = "company1"', ctx);
query(data, 'companies.name | id == $id', ctx);
// 'Acme Corporation'
// Register a scoped custom transform
ctx.registerTransform('double', (value) => String(value).repeat(2));
query(data, 'status\\double()', ctx);
// 'activeactive'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, is structurally malformed, or contains a stray | 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
}
try {
// Multiple | delimiters — use && or || inside a single filter instead
query(data, 'companies.name | type == "corporation" | isActive == "true"');
} catch (e) {
// Error: Invalid filter expression: "..." contains a stray "|".
// Use "||" for logical OR, or split into separate query() calls.
}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 |
For like, matching is SQL-style rather than regex-based: % matches any sequence, _ matches a single character, and characters such as ., (, ) and [ keep their literal meaning.
// 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
