JSONPath
j17 uses a subset of JSONPath for accessing values in events, state, and bindings. This document covers the syntax, resolution contexts, and extensions available in handlers and implications.
Basic syntax
All paths start with $ representing the root of the document being resolved against.
Field access
$.name // Top-level field
$.data.profile.name // Nested field access
Given this JSON:
{"name": "Alice", "profile": {"email": "alice@example.com"}}
| Path | Result |
|---|---|
$ |
Entire object |
$.name |
"Alice" |
$.profile.email |
"alice@example.com" |
$.missing |
null (not found) |
Array indexing
$.items[0] // First element
$.items[2] // Third element
$.items[-1] // Last element
$.items[-2] // Second to last
Given {"items": ["a", "b", "c", "d"]}:
| Path | Result |
|---|---|
$.items[0] |
"a" |
$.items[-1] |
"d" |
$.items[-2] |
"c" |
$.items[99] |
null (out of bounds) |
Mixed access
Dot notation and array indexing can be combined freely:
$.data.items[0].name
$.users[-1].profile.email
$.matrix[0][1]
Resolution contexts
JSONPath resolves against different data depending on where it appears.
Common event paths
These paths are available in all contexts (handlers, implications, sagas):
| Path | Description | Example value |
|---|---|---|
$.key |
Aggregate key (type:id) | "user:a1b2c3d4-..." |
$.id |
Aggregate ID (the ID portion after the colon — typically a UUID, humane code, or global) |
"a1b2c3d4-..." |
$.type |
Event type | "was_created" |
$.data.* |
Event data payload | $.data.user_id |
$.metadata.* |
Event metadata | $.metadata.timestamp |
$.metadata.actor.* |
Who performed the action | $.metadata.actor.id |
@.* |
Aggregate state | @.status |
In handlers
In Tick handlers, $ resolves against the event and @ resolves against the current aggregate state:
| Path prefix | Resolves against |
|---|---|
$.* |
Event |
@.* |
Current aggregate state (mutates as operations run) |
@ |
Entire state object |
targetfields use state paths (no prefix):"profile.name"@.*paths read from the current aggregate state (mutates as operations run)$.*paths read from the event
{
"if": { "equals": ["@.status", "pending"] },
"then": [
{"set": {"target": "status", "value": "$.data.new_status"}}
]
}
Here @.status reads from the current aggregate state, $.data.new_status reads from the event, and "status" in the target writes to state.
Note: @ in handlers reflects the accumulating state — if an earlier operation in the same handler changes status, a later @.status sees the updated value.
In implications
In implications, @ resolves against the source aggregate state (S0 snapshot — the state at the time the event was written, before implications run):
| Path prefix | Resolves against |
|---|---|
$.* |
Trigger event |
@.* |
Source aggregate state (S0 snapshot) |
$.state.* is a deprecated alias for @.*. It will be removed at or before 1.0.
{
"condition": {"equals": ["@.notifications_enabled", true]},
"emit": {
"aggregate_type": "notification",
"id": "$.metadata.actor.id",
"event_type": "was_sent",
"data": {"placed_by": "@.user_name"}
}
}
In sagas
Sagas have the same event paths plus additional context from the saga workflow:
| Path | Description |
|---|---|
$prev.type |
Previous step's response event type |
$prev.data.* |
Previous step's response data |
$context.<step>.* |
Earlier step's result by name |
$error.message |
Error message (in on_failed) |
$error.step |
Failed step name (in on_failed) |
@.* in sagas resolves against the trigger aggregate's state at saga creation time — a frozen snapshot that doesn't change as the saga progresses.
State paths vs JSONPath
Handlers use two kinds of paths:
| Type | Prefix | Used in | Resolves against |
|---|---|---|---|
| State path | None | target fields |
Aggregate state |
| JSONPath | $ |
value fields |
Event data |
{"set": {"target": "profile.name", "value": "$.data.name"}}
profile.name— state path (where to write in the aggregate)$.data.name— JSONPath (what value to read from the event)
State paths support nested access with dots (profile.address.zip) and are used with set, merge, append, remove, increment, decrement, and filter targets.
Optional paths
Append ? to any JSONPath to make it optional. When the path does not exist, the operation becomes a no-op instead of failing:
{"set": {"target": "memo", "value": "$.data.memo?"}}
| Condition | Behavior |
|---|---|
| Field missing | No-op (field not added to state) |
Field is null |
Sets to null |
| Field present | Sets value normally |
Optional paths work with bindings too:
{"set": {"target": "note", "value": "$found.note?"}}
This is particularly useful for events where some fields are only sometimes present, avoiding the need for conditional wrappers.
Variable bindings
Operations that iterate or look up values create named bindings accessible via $name syntax.
$item — iteration binding
Created by map, every, some, and filter operations. Defaults to $item but can be renamed with the as field:
{
"every": {
"in": "items",
"match": {"equals": ["$item.active", true]}
}
}
{
"map": {
"target": "items",
"as": "$entry",
"apply": [
{"set": {"target": "updated_at", "value": "$.metadata.timestamp"}}
]
}
}
$found — lookup binding
Created by let operations that find an item in a state array:
{
"let": {
"name": "$address",
"find_target": "addresses",
"find_field": "id",
"find_value": "$.data.address_id"
}
}
After this, $address.street resolves to the street field of the found address. Bindings created by let are available to all subsequent operations in the same handler.
Binding sub-paths
Bindings work like JSONPath against the bound value:
"$item.name" // field access on bound value
"$found.profile.email" // nested field access
"$item.tags[0]" // array index on bound value
If the sub-path does not exist, ? can be appended to make it optional:
"$item.optional_field?"
$entries — object to array
Append .$entries to a path to convert an object into an array of {key, value} pairs:
$.data.settings.$entries
Given {"settings": {"theme": "dark", "lang": "en"}}, this produces:
[{"key": "theme", "value": "dark"}, {"key": "lang", "value": "en"}]
Use with map in implications to iterate over object keys:
{
"map": {
"in": "$.data.config.$entries",
"as": "$entry",
"emit": {
"aggregate_type": "audit",
"id": "$entry.key",
"event_type": "config_changed",
"data": {"key": "$entry.key", "value": "$entry.value"}
}
}
}
$entries returns null if the value at the base path is not an object.
$merge — object composition
Combine multiple objects into one using $merge. Objects are merged left to right, with later values overriding earlier ones:
{
"set": {
"target": "result",
"value": {"$merge": ["$found", {"updated": true}, "$.data.extra"]}
}
}
Each item in the $merge array can be:
- A JSONPath resolving to an object ("$.data.profile")
- A binding resolving to an object ("$found")
- A literal object ({"status": "active"})
Non-object items and optional-missing items are silently skipped.
Predicate paths
Predicates in if/then/else conditionals and array operations use JSONPath for comparisons:
{
"if": {"equals": ["$.data.status", "active"]},
"then": [
{"set": {"target": "status", "value": "$.data.status"}}
]
}
Array predicates (every, some) resolve their in field against state for simple paths and against the event for JSONPaths:
{
"every": {
"in": "items",
"match": {"equals": ["$item.done", true]}
}
}
Here "items" resolves against the current aggregate state (no $ prefix), while "$item.done" accesses each array element via the iteration binding.
Practical examples
Set from event data
{"set": {"target": "name", "value": "$.data.name"}}
{"set": {"target": "created_at", "value": "$.metadata.timestamp"}}
{"set": {"target": "created_by", "value": "$.metadata.actor.id"}}
Deep property access
{"set": {"target": "shipping_zip", "value": "$.data.shipping.address.zip_code"}}
Array element access
{"set": {"target": "first_item", "value": "$.data.items[0].name"}}
{"set": {"target": "last_item", "value": "$.data.items[-1].name"}}
Optional fields
{"set": {"target": "note", "value": "$.data.note?"}}
{"set": {"target": "priority", "value": "$.data.priority?"}}
Using bindings with $merge
[
{"let": {"name": "$found", "find_target": "items", "find_field": "id", "find_value": "$.data.item_id"}},
{"set": {"target": "selected", "value": {"$merge": ["$found", {"flag": true}]}}}
]
Implication routing
{
"emit": {
"aggregate_type": "user",
"id": "$.metadata.actor.id",
"event_type": "had_order_placed",
"data": {"order_key": "$.key", "customer": "@.customer_name"}
}
}
Iterating object keys
{
"map": {
"in": "$.data.settings.$entries",
"as": "$setting",
"emit": {
"aggregate_type": "audit",
"id": "$setting.key",
"event_type": "setting_changed",
"data": {"value": "$setting.value"}
}
}
}
Not supported
j17 uses a focused subset of JSONPath. The following standard JSONPath features are not implemented:
| Feature | Syntax | Alternative |
|---|---|---|
| Wildcard | $.*, $.items[*] |
Use explicit paths or map |
| Recursive descent | $..name |
Use explicit nested paths |
| Filter expressions | $.items[?(@.active)] |
Use filter operation with predicates |
| Slice | $.items[0:3] |
Use individual indices |
| Union | $.items[0,2] |
Use individual indices |
| Script expressions | $.items[(@.length-1)] |
Use [-1] for last element |
| Bracket notation for fields | $.data["name"] |
Use dot notation $.data.name |
For complex data transformation, use Tick operations (filter, map, every, some) or WASM handlers.
Quick reference
Event paths (all contexts)
| Syntax | Description |
|---|---|
$.key |
Aggregate key (user:abc123) |
$.id |
Aggregate ID (abc123) |
$.type |
Event type |
$.data.* |
Event data |
$.metadata.* |
Event metadata (actor, timestamp, target) |
@.* |
Aggregate state |
$.path? |
Optional (no-op if missing) |
Handler-specific
| Syntax | Description |
|---|---|
"field" (no prefix) |
State path (handler targets, predicate in) |
$binding.field |
Variable binding (after let, in map/every/some) |
$.path.$entries |
Object to {key, value} array |
{"$merge": [...]} |
Shallow object merge |
Saga-specific
| Syntax | Description |
|---|---|
$prev.type |
Previous step's response type |
$prev.data.* |
Previous step's response data |
$context.<step>.* |
Step result by name |
$error.message |
Error message (in on_failed) |
$error.step |
Failed step name (in on_failed) |
Deprecated
| Syntax | Use instead |
|---|---|
$.state.* |
@.* |
See also
- Tick reference — using JSONPath in handlers
- Implications guide — using JSONPath in implications
- Sagas guide — saga-specific templates