Building a JSONPath Tester That Does One Thing Differently
There are already several online JSONPath testers, and most of them work. Building another one is only defensible if it does something the others do not, so this is a note about the three decisions that justified it — and one bug that took longer to find than the parser took to write.
Every result carries its own path
Run $..price against a document in most testers and you get back:
Five numbers. Which one is the bicycle? You are now counting array indices by hand against a document that scrolls off the screen, which is exactly the task you came to the tool to avoid.
The evaluator here carries a path alongside every intermediate node, so the same query returns:
The bicycle is obvious now, and each of those paths is copy-pasteable into your own code to reach that exact value. This costs almost nothing to implement if you do it from the start — the node type is { path, value } instead of bare value, and each step appends a segment:
Retrofitting it later means touching every step handler, which is presumably why tools that did not start this way never added it.
The filters refuse to coerce types
JSONPath filter semantics are underspecified in the 2007 original, and implementations disagree. A common choice is to coerce operands, so [?(@.count > '5')] compares a number against a string and returns something.
This implementation returns nothing instead, and comparison operators only ever compare number-to-number or string-to-string:
Coercion is friendlier right up to the moment it is catastrophic. String comparison puts "10" before "9", so a filter that has been quietly coercing your numeric IDs works perfectly on a test fixture with single-digit values and fails the day you cross into double digits. Returning zero matches is annoying and obvious. Returning wrong matches is neither.
A tool that silently does something reasonable with nonsense input has chosen the user's convenience over the user's correctness.
Bad expressions fail loudly
The related decision: an invalid expression raises an error with a position, rather than returning an empty result set.
Empty results and syntax errors look identical in a tool that swallows both, and they have completely different fixes. Distinguishing them is most of the debugging value.
There is one case that deliberately stays quiet, because the specification requires it: a filter referencing a key that does not exist is false, not an error. [?(@.pirce < 10)] is a valid expression that legitimately matches nothing. That is why the interface always shows a match count — so "zero" is a visible answer rather than an empty space you have to interpret.
The bug: recursive descent as its own step
The evaluator walks the path one step at a time, mapping a list of nodes to the next list of nodes. The natural-looking way to handle ..name is to treat it as a single step that searches the whole subtree for name.
That works until you write $..book[?(@.price < 10)], where the descent and the thing that follows it need to compose. The fix is to stop treating .. as part of the following step and make it a step of its own — one that emits the current node plus every descendant:
Then $..book[?(...)] is simply three steps — descend, child book, filter — and it composes with everything else for free. $..*, $..[0] and $..book[-1] all started working the moment the special case went away.
This is the recurring shape of parser bugs: something that looks like one operation is two, and every test you write against the fused version passes while the composition stays broken.
What RFC 9535 changed, and why the tool predates it in places
JSONPath was a 2007 blog post with a reference implementation, not a specification. RFC 9535 finally standardised it in February 2024, and the ecosystem has not caught up.
This implementation follows the RFC on the things that matter — no type coercion, both quote styles accepted, script expressions dropped — but keeps =~ for regex matching, which the RFC does not define. Jayway supports it, a lot of real-world expressions use it, and dropping it would break people for purity. That is a deliberate, documented divergence rather than an accident, which is the most you can ask of a JSONPath implementation right now.
The full list of where implementations disagree is in the JSONPath cheatsheet. If you have ever had an expression work in Postman and fail in kubectl, that table is the reason.
Shape of the thing
About 600 lines of plain JavaScript in one file, no dependencies, no build step. A recursive-descent parser producing a step list, a small expression compiler for filters that returns a closure per filter, and an evaluator that folds the step list over a node list. It runs in the browser tab, so nothing you paste into it is uploaded anywhere.
The parser was an afternoon. The descent bug was the rest of the day.
Try it, or read jsonpath.js — at this size it is genuinely readable end to end, which was the other point of writing it without dependencies.