Larcanum.JsonPath 1.0.0

dotnet add package Larcanum.JsonPath --version 1.0.0
                    
NuGet\Install-Package Larcanum.JsonPath -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Larcanum.JsonPath" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Larcanum.JsonPath" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="Larcanum.JsonPath" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Larcanum.JsonPath --version 1.0.0
                    
#r "nuget: Larcanum.JsonPath, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Larcanum.JsonPath@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Larcanum.JsonPath&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=Larcanum.JsonPath&version=1.0.0
                    
Install as a Cake Tool

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 (and JsonObject/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 throws JsonPathParseException with 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 or JsonArray, 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.0 only — 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 NameSelector has 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, DescendantSegment reuses the exact same selector-application logic that ChildSegment uses 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

MIT

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • 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