A Java library that turns path expressions into generated bytecode instead of interpreting strings at runtime is closing the gap with hand-written native access. The interesting part isn't the speed number. It's what you give up to get it.

Every path query library you have used at runtime, whether it walks JSON, navigates an object graph, or resolves a configuration key, pays the same tax. You hand it a string like $.orders[0].lineItems.total, and somewhere inside, that string gets parsed into tokens, those tokens get assembled into an abstract syntax tree, and a tree-walking interpreter visits each node to figure out where to go. The work of understanding the path happens over and over, on every single call, even though the path itself never changes.
@CompiledPath attacks that tax at its root. The premise stated in the title is blunt: no parser, no interpreter, just Java. The path expression is read once, at compile time, by an annotation processor. From it, the processor generates plain Java code that does the navigation directly. By the time your application runs, there is no string to parse and no tree to walk. There is only a method that reaches into the object and pulls out the value, the same code you would have written by hand if you had the patience to write it for every path in your system.
The problem with interpretation
To understand why this matters, it helps to separate two costs that runtime path libraries blend together.
The first is parsing. Turning $.orders[0].lineItems.total into a structured representation involves scanning characters, recognizing tokens, and building nodes. Mature libraries cache the parsed result, so for a fixed set of expressions this cost amortizes toward zero. If your paths are constants, caching mostly solves parsing.
The second cost is the harder one: interpretation. Even with a cached AST, every evaluation walks that tree. Each node carries a type tag, and the interpreter branches on that tag to decide what to do. Visit a property node, look up a key. Visit an index node, dereference an array. These branches are unpredictable to the CPU because the shape of the tree varies from path to path, and the indirection through polymorphic node objects defeats inlining. The JIT compiler sees a generic evaluate method that handles every possible path shape, so it cannot specialize for the one path you actually care about.
Generated code removes both. For the path above, the processor emits something close to root.getOrders().get(0).getLineItems().getTotal(). There is no type tag to branch on, no node object to dereference, no megamorphic call site. The JIT sees ordinary field and method access and optimizes it exactly as it would optimize code you typed yourself. That is what "close to native performance" means here. The generated path is native Java; the only gap left is whatever overhead the generation strategy itself introduces.
How the generation works
The mechanism is Java's annotation processing API, the same javax.annotation.processing infrastructure that powers tools like Lombok, MapStruct, and Dagger. During compilation, the processor inspects elements annotated with @CompiledPath, reads the path string from the annotation, resolves it against the declared types using the compiler's type mirror model, and writes a new source file or class that implements the navigation.
The type resolution step is where the design earns its keep. Because the processor runs inside the compiler, it knows the actual types involved. It can confirm that orders returns a List<Order>, that Order has a lineItems accessor, and that the chain ends in a numeric total. A path that does not type-check fails the build. This is a meaningful shift in where errors surface. A runtime JSONPath library discovers a typo or a renamed field when the query executes in production, often as a null or a thrown exception buried in a request handler. The compiled approach discovers the same mistake before the artifact is ever built. The cost of a broken path moves from incident to red squiggle.
The generated code also opens the door to optimizations an interpreter cannot reach. Null handling can be inlined as explicit checks rather than wrapped in Optional allocations. Index access can be specialized for arrays versus lists. Repeated subpaths can be hoisted into local variables. None of this requires runtime reflection, so the result stays AOT-friendly, which matters if you compile to a native image with GraalVM where runtime reflection is a constant source of configuration pain.
The trade-offs you are actually making
A distributed systems habit is useful here: nothing is free, so the real question is what moved, not what improved. Compiling paths buys throughput and earlier error detection, and it pays for them in three currencies.
The first is dynamism. An interpreted path library accepts a path as data. You can read expressions from a config file, build them from user input, or assemble them at runtime, and the same engine handles all of it. A compiled path must be known to the compiler. If your use case is genuinely dynamic, where paths arrive over the network or are chosen by an end user, code generation does not apply. You cannot compile what you do not yet have. The honest framing is that @CompiledPath is for the large category of paths that are fixed in the source but were being treated as runtime data out of convenience.
The second is build complexity. Annotation processors run on every compilation, and they couple your build to a tool that emits code you do not see in your editor. When a generated path misbehaves, debugging means reading machine-written source, and stack traces point into files no human authored. Teams that have lived with heavy annotation processing know the failure modes: incremental compilation that silently goes stale, IDE integration that lags the command-line build, and the occasional processor that does not play well with another. These are manageable, but they are real operational weight added to the build pipeline.
The third is the granularity of change. A runtime expression can be edited and redeployed as configuration. A compiled path is part of the binary. Changing it means a recompile and a redeploy. For systems that value the ability to adjust behavior without shipping a new artifact, that rigidity is a downgrade, not an upgrade. The compiled approach assumes paths are code, with all the release discipline that implies, and that assumption is correct far more often than runtime path strings would suggest.
Where this pattern fits
The broader pattern, moving work from runtime to compile time, is one of the more reliable performance strategies in any system, not just Java. Query planners that prepare statements, protocol buffers that generate serializers, and template engines that compile to functions all follow the same logic: pay the analysis cost once, when you have the most context and the least time pressure, so that the hot path carries the least possible weight.
For hot paths in high-throughput services, the case is strong. A request handler that extracts a handful of fields from a large payload, executed millions of times an hour, is exactly where interpretation overhead compounds into measurable latency and CPU cost. Replacing it with generated accessors removes a layer that the profiler would otherwise keep flagging. For a script run twice a day, the same change buys nothing worth the build-time complexity, and a plain runtime library remains the saner choice.
The useful takeaway is not that compilation beats interpretation. It is that the two sit at different points on a flexibility-versus-speed curve, and most projects pick a point by default rather than by decision. Libraries like this one make the fast end of that curve cheap enough to choose deliberately. If your paths are constant, known at build time, and sit on a hot path, you were paying for flexibility you never used. @CompiledPath is a way to stop paying for it, as long as you are honest that the flexibility is what you are handing back in return.

Comments
Please log in or register to join the discussion