|
| 1 | +# jsonpath-engine |
| 2 | + |
| 3 | +TypeScript-first, RFC 9535-compliant JSONPath query engine. |
| 4 | + |
| 5 | +JSONPath is a small query language for JSON, in the same spirit as XPath for XML or CSS selectors for the DOM. `jsonpath-engine` implements [RFC 9535](https://www.rfc-editor.org/rfc/rfc9535), the IETF standard that defines JSONPath's syntax, semantics, and well-typedness rules. |
| 6 | + |
| 7 | +## Installation |
| 8 | + |
| 9 | +```sh |
| 10 | +npm install jsonpath-engine |
| 11 | +# or |
| 12 | +pnpm add jsonpath-engine |
| 13 | +# or |
| 14 | +yarn add jsonpath-engine |
| 15 | +``` |
| 16 | + |
| 17 | +## Basic usage |
| 18 | + |
| 19 | +```ts |
| 20 | +import { query, paths, nodes, first, exists } from 'jsonpath-engine' |
| 21 | + |
| 22 | +const document = { |
| 23 | + store: { |
| 24 | + book: [ |
| 25 | + { category: 'reference', author: 'Nigel Rees', title: 'Sayings of the Century', price: 8.95 }, |
| 26 | + { category: 'fiction', author: 'Evelyn Waugh', title: 'Sword of Honour', price: 12.99 }, |
| 27 | + { category: 'fiction', author: 'Herman Melville', title: 'Moby Dick', price: 8.99 }, |
| 28 | + ], |
| 29 | + bicycle: { color: 'red', price: 19.95 }, |
| 30 | + }, |
| 31 | +} |
| 32 | + |
| 33 | +query(document, '$.store.book[*].title') |
| 34 | +// ["Sayings of the Century", "Sword of Honour", "Moby Dick"] |
| 35 | + |
| 36 | +query(document, '$.store.book[?@.price < 10].title') |
| 37 | +// ["Sayings of the Century", "Moby Dick"] |
| 38 | + |
| 39 | +paths(document, '$..price') |
| 40 | +// ["$['store']['book'][0]['price']", "$['store']['book'][1]['price']", ...] |
| 41 | + |
| 42 | +nodes(document, '$.store.book[0]') |
| 43 | +// [{ value: { category: "reference", ... }, path: "$['store']['book'][0]" }] |
| 44 | + |
| 45 | +first(document, '$.store.book[0].title') |
| 46 | +// "Sayings of the Century" |
| 47 | + |
| 48 | +exists(document, "$.store.book[?@.author == 'Nigel Rees']") |
| 49 | +// true |
| 50 | +``` |
| 51 | + |
| 52 | +## Compile once, run many |
| 53 | + |
| 54 | +`compile` parses a query and runs the well-typedness check up front, so syntax and type errors raise at compile time rather than every evaluation. The result exposes the same five operations as the one-shot helpers and can be reused across documents. |
| 55 | + |
| 56 | +```ts |
| 57 | +import { compile } from 'jsonpath-engine' |
| 58 | + |
| 59 | +const cheapBookTitles = compile('$.store.book[?@.price < 10].title') |
| 60 | + |
| 61 | +for (const document of largeStream) { |
| 62 | + console.log(cheapBookTitles.query(document)) |
| 63 | +} |
| 64 | +``` |
| 65 | + |
| 66 | +`cheapBookTitles.source` returns the original query string for logging. |
| 67 | + |
| 68 | +## Custom functions |
| 69 | + |
| 70 | +The standard functions are pre-registered. Define your own with `defineFunction`, and the parameter and return types are inferred from the declared `argTypes` and `resultType`. |
| 71 | + |
| 72 | +```ts |
| 73 | +import { defineFunction, registerFunction, LogicalTrue, LogicalFalse, query } from 'jsonpath-engine' |
| 74 | + |
| 75 | +const startsWith = defineFunction({ |
| 76 | + argTypes: ['ValueType', 'ValueType'] as const, |
| 77 | + resultType: 'LogicalType', |
| 78 | + evaluate(subject, prefix) { |
| 79 | + // subject and prefix are typed as ValueType, no manual narrowing needed |
| 80 | + if (typeof subject !== 'string' || typeof prefix !== 'string') { |
| 81 | + return LogicalFalse |
| 82 | + } |
| 83 | + return subject.startsWith(prefix) ? LogicalTrue : LogicalFalse |
| 84 | + }, |
| 85 | +}) |
| 86 | + |
| 87 | +registerFunction('starts_with', startsWith) |
| 88 | + |
| 89 | +query(users, "$[?starts_with(@.name, 'A')]") |
| 90 | +``` |
| 91 | + |
| 92 | +Function names follow the RFC 9535 ABNF: a lowercase letter followed by lowercase letters, digits, and underscores. Standard names (`length`, `count`, `value`, `match`, `search`) are reserved. |
| 93 | + |
| 94 | +## JSONPath syntax |
| 95 | + |
| 96 | +| Construct | Syntax | Example | |
| 97 | +| --------------------- | ------------------------ | ------------------------------------ | |
| 98 | +| Root identifier | `$` | `$` returns the whole document | |
| 99 | +| Member-name shorthand | `.name` | `$.store.book` | |
| 100 | +| Bracketed member name | `['name']` or `["name"]` | `$['store']['book']` | |
| 101 | +| Wildcard | `*` or `.*` | `$.store.book[*]` | |
| 102 | +| Index selector | `[N]` | `$.store.book[0]` | |
| 103 | +| Negative index | `[-N]` | `$.store.book[-1]` (last) | |
| 104 | +| Slice selector | `[start:end:step]` | `$.store.book[0:2]`, `$..book[::-1]` | |
| 105 | +| Filter selector | `[?expr]` | `$.store.book[?@.price < 10]` | |
| 106 | +| Descendant segment | `..` | `$..price`, `$..book[?@.isbn]` | |
| 107 | +| Multiple selectors | `[a, b, c]` | `$['store']['book'][0, 2]` | |
| 108 | +| Current node | `@` | `[?@.price < 10]` inside a filter | |
| 109 | + |
| 110 | +Filter expression operators by precedence, highest first: comparison (`< <= > >= == !=`), logical not (`!`), logical and (`&&`), logical or (`||`). Parentheses force grouping. |
| 111 | + |
| 112 | +The five standard functions, per RFC 9535 sections 2.4.4 through 2.4.8: |
| 113 | + |
| 114 | +| Function | Signature | Notes | |
| 115 | +| ------------------------ | ------------------------------------- | ----------------------------------------------------------------------------------------------------------- | |
| 116 | +| `length(value)` | `ValueType -> ValueType` | Unicode code-point count for strings, length for arrays, key count for objects. `Nothing` for other inputs. | |
| 117 | +| `count(nodes)` | `NodesType -> ValueType` | Size of a nodelist. | |
| 118 | +| `value(nodes)` | `NodesType -> ValueType` | The value of a singleton nodelist, or `Nothing`. | |
| 119 | +| `match(value, pattern)` | `ValueType, ValueType -> LogicalType` | Full-string i-regexp match per RFC 9485. | |
| 120 | +| `search(value, pattern)` | `ValueType, ValueType -> LogicalType` | Substring i-regexp match per RFC 9485. | |
| 121 | + |
| 122 | +## Errors |
| 123 | + |
| 124 | +Three error classes, each extending the native parent so existing `instanceof SyntaxError` or `instanceof TypeError` checks keep working. |
| 125 | + |
| 126 | +```ts |
| 127 | +class JsonPathSyntaxError extends SyntaxError { |
| 128 | + readonly query: string |
| 129 | + readonly start: number |
| 130 | + readonly end: number |
| 131 | + readonly code: JsonPathSyntaxErrorCode |
| 132 | +} |
| 133 | + |
| 134 | +class JsonPathTypeError extends TypeError { |
| 135 | + readonly query: string |
| 136 | + readonly start: number |
| 137 | + readonly end: number |
| 138 | + readonly code: JsonPathTypeErrorCode |
| 139 | +} |
| 140 | + |
| 141 | +class JsonPathFunctionError extends Error { |
| 142 | + readonly functionName: string |
| 143 | + readonly code: JsonPathFunctionErrorCode |
| 144 | +} |
| 145 | +``` |
| 146 | + |
| 147 | +Each error has a stable string `code` for programmatic dispatch. `formatJsonPathError` renders a multi-line caret view of the offending span. |
| 148 | + |
| 149 | +```ts |
| 150 | +try { |
| 151 | + compile('$.foo[?(') |
| 152 | +} catch (caught) { |
| 153 | + if (caught instanceof JsonPathSyntaxError) { |
| 154 | + console.error(formatJsonPathError(caught)) |
| 155 | + // JsonPathSyntaxError [empty-filter]: ... |
| 156 | + // 1 | $.foo[?( |
| 157 | + // | ^ |
| 158 | + } |
| 159 | +} |
| 160 | +``` |
| 161 | + |
| 162 | +## API |
| 163 | + |
| 164 | +### Query helpers |
| 165 | + |
| 166 | +```ts |
| 167 | +function query(json: JsonValue, path: string): JsonValue[] |
| 168 | +function paths(json: JsonValue, path: string): NormalizedPath[] |
| 169 | +function nodes(json: JsonValue, path: string): JsonPathNode[] |
| 170 | +function first(json: JsonValue, path: string): JsonValue | undefined |
| 171 | +function exists(json: JsonValue, path: string): boolean |
| 172 | + |
| 173 | +function compile(path: string): CompiledJsonPath |
| 174 | +``` |
| 175 | + |
| 176 | +```ts |
| 177 | +type CompiledJsonPath = { |
| 178 | + readonly source: string |
| 179 | + query(json: JsonValue): JsonValue[] |
| 180 | + paths(json: JsonValue): NormalizedPath[] |
| 181 | + nodes(json: JsonValue): JsonPathNode[] |
| 182 | + first(json: JsonValue): JsonValue | undefined |
| 183 | + exists(json: JsonValue): boolean |
| 184 | +} |
| 185 | +
|
| 186 | +type JsonPathNode = { |
| 187 | + readonly value: JsonValue |
| 188 | + readonly path: NormalizedPath |
| 189 | +} |
| 190 | +
|
| 191 | +type Nodelist = readonly JsonPathNode[] |
| 192 | +type NormalizedPath = string |
| 193 | +``` |
| 194 | + |
| 195 | +### Function registry |
| 196 | + |
| 197 | +```ts |
| 198 | +function defineFunction<Args, Result>(extension: { |
| 199 | + argTypes: Args |
| 200 | + resultType: Result |
| 201 | + evaluate(...args): RuntimeOf<Result> |
| 202 | +}): JsonPathFunctionExtension |
| 203 | +
|
| 204 | +function registerFunction(name: string, extension: JsonPathFunctionExtension): void |
| 205 | +function unregisterFunction(name: string): boolean |
| 206 | +function listFunctions(): readonly string[] |
| 207 | +``` |
| 208 | + |
| 209 | +### Runtime type system |
| 210 | + |
| 211 | +The runtime types from RFC 9535 section 2.4.1, plus type guards and the `nodelistToValue` conversion from section 2.4.3. |
| 212 | + |
| 213 | +```ts |
| 214 | +const Nothing: unique symbol |
| 215 | +const LogicalTrue: unique symbol |
| 216 | +const LogicalFalse: unique symbol |
| 217 | +
|
| 218 | +function isNothing(value: JsonPathFunctionArg): boolean |
| 219 | +function isLogicalTrue(value: JsonPathFunctionArg): boolean |
| 220 | +function isLogicalFalse(value: JsonPathFunctionArg): boolean |
| 221 | +function isNodesType(value: JsonPathFunctionArg): boolean |
| 222 | +function nodelistToValue(nodes: NodesType): ValueType |
| 223 | +``` |
| 224 | + |
| 225 | +## License |
| 226 | + |
| 227 | +[MIT License](./LICENSE) © 2026-present [Cong Nguyen](https://github.com/chicong065) |
0 commit comments