A secure, expression-evaluation-free JSONPath implementation for TypeScript/JavaScript with RFC 9535 compliance.
Live Demo | RFC 9535 Specification
- RFC 9535 compliant - Follows the official JSONPath specification and passes the whole JSONPath Compliance Test Suite: valid queries give the expected results, invalid ones are rejected
- Secure - No dynamic code execution, no injection vulnerabilities
- TypeScript native - Full type definitions included
- Dual module - Works with ESM and CommonJS
- Browser ready - Works in Node.js and browsers
npm install jsonpathlyimport { query, paths } from "jsonpathly";
const championship = {
name: "World Chess Championship 2023",
players: [
{ name: "Ding Liren", rating: 2788, country: "China" },
{ name: "Ian Nepomniachtchi", rating: 2795, country: "Russia" },
],
};
// Query values
query(championship, "$.players[*].name");
// => ["Ding Liren", "Ian Nepomniachtchi"]
// Get paths
paths(championship, "$.players[0]");
// => ["$['players'][0]"]
// Filter expressions
query(championship, "$.players[?@.rating > 2790].name");
// => ["Ian Nepomniachtchi"]JSONPath expressions start with $ (root) and can use dot or bracket notation:
$.players[0].name
$['players'][0]['name']
| Operator | Description |
|---|---|
$ |
Root element |
@ |
Current element in filter expressions |
* |
Wildcard - matches all elements |
.. |
Recursive descent |
.<name> |
Dot-notated child |
['<name>'] |
Bracket-notated child |
[<index>] |
Array index |
[start:end:step] |
Array slice |
[?<expression>] |
Filter expression |
[<expr>, <expr>] |
Union - multiple selectors |
| Operator | Description | Example |
|---|---|---|
== |
Equal | $[?@.title == 'Grandmaster'] |
!= |
Not equal | $[?@.country != 'Russia'] |
< |
Less than | $[?@.rating < 2700] |
<= |
Less than or equal | $[?@.rating <= 2700] |
> |
Greater than | $[?@.rating > 2800] |
>= |
Greater than or equal | $[?@.rating >= 2800] |
&& |
Logical AND | $[?@.rating > 2700 && @.active] |
|| |
Logical OR | $[?@.champion || @.challenger] |
! |
Logical NOT | $[?!@.retired] |
=~ |
Regex match | $[?@.name =~ /^Magnus/i] |
| Function | Description | Example |
|---|---|---|
length() |
Length of string, array, or object | $[?length(@.name) > 10] |
count() |
Number of nodes in a nodelist | $[?count(@.titles[*]) > 5] |
match() |
Full string regex match | $[?match(@.title, '^GM.*')] |
search() |
Regex search within string | $[?search(@.name, 'Kasparov')] |
value() |
Extract value from nodelist | $[?value(@.achievements[0]) == 'World Champion'] |
| Operator | Description | Example |
|---|---|---|
in |
Value exists in array | $[?@.federation in ['FIDE', 'USCF']] |
nin |
Value not in array | $[?@.result nin ['loss']] |
subsetof |
Left is subset of right | $[?@.titles subsetof ['GM', 'IM', 'FM']] |
anyof |
Any element in common | $[?@.styles anyof ['positional', 'tactical']] |
noneof |
No elements in common | $[?@.weaknesses noneof ['time trouble']] |
size |
Array/string length equals | $[?@.championships size 5] |
empty |
Array/string is empty | $[?@.losses empty] |
Query JSON data and return matching values.
import { query } from "jsonpathly";
const legends = {
champions: [
{ name: "Garry Kasparov", era: "1985-2000", rating: 2851 },
{ name: "Magnus Carlsen", era: "2013-2023", rating: 2882 },
],
};
query(legends, "$.champions[*].name");
// => ["Garry Kasparov", "Magnus Carlsen"]
query(legends, "$.champions[0].era");
// => "1985-2000"Options:
| Option | Type | Description |
|---|---|---|
hideExceptions |
boolean |
Return undefined instead of throwing (or [] if returnArray is true) |
returnArray |
boolean |
Always return results as an array |
maxSteps |
number |
Evaluation steps allowed before throwing JSONPathLimitError (default: the larger of 1,000,000 and 100 per document node or string character, plus a linear regex scan of the document's strings; Infinity disables it). With the default budget, a query may also place at most the larger of 1,000,000 and 20 per document node nodes in its nodelists; an explicit maxSteps bounds those on its own, since each is a step |
Get normalized paths to matching elements.
import { paths } from "jsonpathly";
const tournament = {
candidates: [
{ player: "Bobby Fischer", year: 1971 },
{ player: "Anatoly Karpov", year: 1974 },
],
};
paths(tournament, "$..player");
// => ["$['candidates'][0]['player']", "$['candidates'][1]['player']"]Options:
| Option | Type | Description |
|---|---|---|
hideExceptions |
boolean |
Return empty array instead of throwing |
maxSteps |
number |
Same as for query() |
Parse a JSONPath expression into an AST.
import { parse } from "jsonpathly";
parse("$.players[0].rating");
// => { type: 'root', next: { type: 'subscript', ... } }Convert an AST back to a JSONPath string.
import { parse, stringify } from "jsonpathly";
stringify(parse("$.champions[*].name"));
// => "$.champions[*].name"Custom error class for syntax errors.
import { query, JSONPathSyntaxError } from "jsonpathly";
try {
query({}, "$[invalid");
} catch (e) {
if (e instanceof JSONPathSyntaxError) {
console.log("Invalid JSONPath:", e.message);
}
}Thrown when a query needs more evaluation steps than maxSteps allows (a step is a node selected or visited, a selector tried, an expression evaluated, a value compared, or a string character compared or scanned by a regex). Typical queries, linear in the document, stay below the default; nested descendant filters such as $..[?@..[?@..a]] grow polynomially and hit it. A heavy but legitimate query (dozens of conditions per element on a large document) may need a higher maxSteps.
import { query, JSONPathLimitError } from "jsonpathly";
try {
query(doc, untrustedPath, { maxSteps: 100_000 });
} catch (e) {
if (e instanceof JSONPathLimitError) {
console.log("Query too expensive");
}
}jsonpathly is designed with security in mind:
- No dynamic code execution - Expressions are parsed into an AST and evaluated safely
- No code injection - Script expressions
$(...)are not supported - I-Regexp compliance - Regex patterns in
match()andsearch()are validated against RFC 9485 - No ReDoS - Regex filters (
=~,match(),search()) run on a linear-time matcher, never on the backtracking engine, so a pattern like(a+)+$cannot hang the event loop. Patterns are limited to 1000 characters, 16\p{...}escapes and 1000 compiled instructions; a larger pattern matches nothing, like an invalid one - Bounded evaluation - Parsing is linear in the query length (filter expressions are memoized, so nesting never makes the parser backtrack exponentially), subqueries on
$and tests that do not read@are evaluated once per query, and evaluation stops withJSONPathLimitErrorpastmaxSteps - No stack overflow - Deeply nested documents and long queries are processed iteratively; an expression nested too deeply to parse is a
JSONPathSyntaxError - Own members only - Selectors only see the document's own members and array elements:
$.constructoror$['__proto__']never returnObjectorObject.prototype, and an index supplied by a non-standard array prototype reads as a hole
These guarantees are for untrusted queries and untrusted JSON documents (the output of JSON.parse, or plain objects and arrays). A document made of getters, Proxies or custom prototypes, or a hand-built AST passed to stringify(), is the caller's own object: each member is checked and read once, lengths are read once per operation, and a reported length that is not a valid array length, or a string field that is not a string, is a TypeError. AST fields are read as JavaScript properties, prototype chain included. Non-enumerable own properties, which JSON.parse never produces, are skipped when an object is enumerated (wildcards, descendants, filters, length()) and not counted by the budget, although the engine scans them; a name selector or an array index still reads one, as in 3.1.0. Memory use is proportional to the document's size: a string built by concatenation is flattened by the engine the first time a character of it is read, as with any consumer of that string. Member names are limited to 1,000 characters: a longer one in a query is a JSONPathSyntaxError, and a query that enumerates or compares an object holding one fails with a JSONPathLimitError (V8 hashes very long strings by their length alone, and no real document needs such names). String values have no limit; the budget charges the one-by-one comparisons that very long ones force in in, subsetof, anyof and noneof.
For a comparison with other JSONPath implementations, see json-path-comparison.
Key differences:
- Uses a Peggy parser instead of dynamic execution
- Follows RFC 9535 semantics for edge cases
- Normalized paths use single quotes per RFC 9535
Queries that RFC 9535 does not allow are now rejected with a JSONPathSyntaxError instead of being evaluated. The most common ones to update:
| Before (3.x) | Now |
|---|---|
$.content-type |
$['content-type'] |
$.items.0, $.items.-1 |
$.items[0], $.items[-1] (arrays), $.items['0'], $.items['-1'] (objects) |
' $.a ' |
'$.a' |
$[?true] |
$[?@] |
$[?length(@.a)] |
$[?length(@.a) > 0] |
$[?@.* == 1] (matched nothing) |
$[?@[?@ == 1]] (a child equals 1) |
Function names must be lowercase, and arithmetic operands, like comparison operands, must be singular queries. match() and search() follow the I-Regexp grammar (no \d, \w, (?:...); \- allowed), length() counts Unicode characters, and strings compare by code point. The extension operators (in, anyof, =~...) still accept any query, and unquoted names in brackets ($[key-dash]) still work. See the changelog for the full list.
- Path format changed:
paths()now returns RFC 9535 normalized format with single quotes:Before: $["players"][0]["name"] After: $['players'][0]['name'] - Node.js 18+ required
- Node.js >= 18
MIT
