Larcanum.JsonPath
1.0.0
dotnet add package Larcanum.JsonPath --version 1.0.0
NuGet\Install-Package Larcanum.JsonPath -Version 1.0.0
<PackageReference Include="Larcanum.JsonPath" Version="1.0.0" />
<PackageVersion Include="Larcanum.JsonPath" Version="1.0.0" />
<PackageReference Include="Larcanum.JsonPath" />
paket add Larcanum.JsonPath --version 1.0.0
#r "nuget: Larcanum.JsonPath, 1.0.0"
#:package Larcanum.JsonPath@1.0.0
#addin nuget:?package=Larcanum.JsonPath&version=1.0.0
#tool nuget:?package=Larcanum.JsonPath&version=1.0.0
Larcanum.JsonPath
A lightweight, zero-dependency (beyond System.Text.Json) JSONPath implementation of RFC 9535 for modern .NET.
Status: early-stage where selectors (
?...) and function extensions (length(),count(), ...) are intentionally not implemented yet — see Limitations.
Why this library
System.Text.Json gives you two, fairly different ways to work with JSON:
JsonElement— the immutable, read-only view over already-parsed JSON, optimized for reading.JsonNode(andJsonObject/JsonArray/JsonValue) — the mutable DOM representation, meant for building and editing JSON.
Both are "the" way to work with JSON in .NET depending on what you're doing, and neither has built-in support for querying into a document with a path expression. If you want "give me the price of every book under $.store.book[*]", you're writing a manual recursive walk by hand — twice, once for each representation, because their APIs don't share a common shape.
Larcanum.JsonPath solves that with a single JSONPath query engine that works identically against both representations (and can be extended to others — see Extension Points), exposed through simple extension methods.
Quick start
using System.Text.Json;
using Larcanum.JsonPath;
var json = """
[
{ "foo": 42, "bar": "Hello" },
{ "foo": 666 },
{ "bar": "World", "baz": true }
]
""";
var element = JsonSerializer.Deserialize<JsonElement>(json);
element.Get<long>("$[0].foo"); // 42 — throws if there are 0 or more than 1 matches
element.GetFirst<string>("$..bar"); // "Hello" — returns default(T) if there is no match
element.GetAll("$[0,1]"); // IEnumerable<JsonElement> with the first two array elements
The exact same three methods — Get<T>, GetFirst<T>, GetAll — exist for JsonNode?, with identical semantics:
using System.Text.Json.Nodes;
using Larcanum.JsonPath;
var node = JsonNode.Parse(json);
node.Get<long>("$[0].foo");
node.GetFirst<string>("$..bar");
node.GetAll("$[0,1]");
| Method | Behavior |
|---|---|
Get<T>(path) |
Evaluates path and deserializes the single matching value to T. Throws InvalidOperationException if there are zero or more than one matches. |
GetFirst<T>(path) |
Evaluates path and deserializes the first matching value to T. Returns default(T) if there is no match. |
GetAll(path) |
Evaluates path and returns every matching value as IEnumerable<JsonElement> / IEnumerable<JsonNode?>. |
A malformed query throws JsonPathParseException, which carries a Position pointing at the offending character.
Supported JSONPath syntax
Everything in RFC 9535 except filter selectors and function extensions (see Limitations):
| Syntax | Selector | Meaning |
|---|---|---|
$ |
— | The root node itself. |
.name / ['name'] / ["name"] |
Name | The value of object member name. Quoted forms support the RFC's escape sequences, including \uXXXX. |
.* / [*] |
Wildcard | Every member value of an object, or every element of an array. |
[n] |
Index | The array element at index n. Negative indices count from the end ([-1] is the last element). |
[start:end:step] |
Slice | A range of array elements, with Python-style slice semantics (all three parts optional). |
[sel1, sel2, ...] |
Union | Multiple selectors combined; results are concatenated in the order the selectors are listed. |
..name / ..[...] / ..* |
Descendant segment | Applies the wrapped selector(s) to the current node and recursively to every descendant, in preorder, depth-first order. |
element.GetAll("$.store.book[*].author"); // every book's author, in document order
element.GetAll("$..price"); // every "price" field, at any depth
element.GetAll("$..book[-1]"); // the last book in every "book" array, at any depth
element.GetAll("$[1:5:2]"); // every 2nd array element from index 1 up to (excl.) 5
Limitations
- Filter selectors are not supported (
$..book[?@.price < 10]). Parsing a query containing?inside a bracketed selection throwsJsonPathParseExceptionwith a message that says so explicitly, rather than a generic syntax error. - Function extensions are not supported (
length(),count(),match(),search(),value()), since they only appear inside filter expressions. - No normalized-path output. The library only evaluates queries to values; it does not produce RFC 9535 §2.7 normalized paths for the matched locations.
- Indices and slice bounds are
int(32-bit), not the RFC's arbitrary-precision integers — more than sufficient for anything that fits in a .NET array orJsonArray, but worth knowing if you're porting queries from another implementation. - Evaluation is eager: each segment's results are fully materialized into a
List<TNode>before the next segment runs; there is no streaming/pull-based evaluation. - Targets
net10.0only — there's no multi-targeting down to older TFMs yet.
None of this is a permanent design constraint — filter selectors and function extensions are the natural next milestone; they were deliberately scoped out to keep the first version's grammar, AST, and evaluator small and easy to validate against the RFC.
Architecture & extension points
A query is parsed into a small AST: JsonPathQuery.Parse(path) returns a JsonPathQuery, which is just an ordered list of JsonPathSegments (ChildSegment or DescendantSegment), each carrying a list of JsonPathSelectors (NameSelector, WildcardSelector, IndexSelector, SliceSelector). Both JsonPathSegment and JsonPathSelector expose an Apply<TNode> method that does the actual work of navigating one JSON node.
Segments and selectors are deliberately kept as two independent hierarchies rather than combined into one:
- It mirrors the RFC's own grammar. Per Section 2.5,
segment = child-segment / descendant-segment, and both segment kinds wrap the same selector grammar — any selector is legal in either segment kind ($['a',0,1:3]and$..['a',0,1:3]both mix name/index/slice selectors freely). That's how deep to look (child vs. descendant) crossed with what to pick out of a node (name/wildcard/index/slice) — two orthogonal choices. Mapping the AST 1:1 onto that keeps both hierarchies small and closed, instead of a combinatorial cross-product (ChildNameSegment,DescendantNameSegment,ChildIndexSegment, ...). - The two axes have genuinely different algorithms. Child scope applies each selector once to each input node. Descendant scope is a preorder, depth-first recursive walk that applies the same selector list at every level it visits. That recursion is a property of where you look, not of any individual selector — a
NameSelectorhas no notion of "recursion," it only ever answers "given one object node, what's the value of this member." Keeping the recursive-descent logic in exactly one place (DescendantSegment.Apply) keeps each selector trivial. - It composes instead of multiplying. Because both segment types expose the same
IReadOnlyList<JsonPathSelector>shape,DescendantSegmentreuses the exact same selector-application logic thatChildSegmentuses at every level it visits. Segment = traversal strategy, selector = extraction strategy, composed rather than cross-multiplied.
Crucially, Apply<TNode> never touches JsonElement or JsonNode directly. It navigates purely through IJsonPathVisitor<TNode>:
public interface IJsonPathVisitor<TNode>
{
JsonPathNodeKind GetKind(TNode node);
bool TryGetProperty(TNode node, string name, out TNode value);
int GetArrayLength(TNode node);
TNode GetArrayItem(TNode node, int index);
IEnumerable<KeyValuePair<string, TNode>> GetObjectMembers(TNode node);
}
JsonElementVisitor and JsonNodeVisitor are the two built-in implementations — each is a small, self-contained adapter (under 50 lines) translating those five operations into the corresponding JsonElement/JsonNode API calls. This is the extension point: JSONPath support for a new JSON representation (e.g. MongoDB's BsonValue, or a custom document model) is a matter of implementing IJsonPathVisitor<TYourNodeType>, then evaluating with it directly:
var query = JsonPathQuery.Parse("$..price");
var results = query.Evaluate(root, MyCustomVisitor.Instance); // List<TYourNodeType>
JsonElementVisitor.cs is the best starting point to copy from — it's the simplest of the two built-in visitors, since JsonElement has no notion of a distinguished null node the way JsonNode? does.
License
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0 | 48 | 8/22/2026 |