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.
In handlers
In Tick handlers, $ resolves against the event:
| Path prefix | Resolves against | Example |
|---|---|---|
$.data.* |
Event data payload | $.data.user_id |
$.metadata.* |
Event metadata | $.metadata.timestamp |
$.metadata.actor.* |
Who performed the action | $.metadata.actor.id |
$.key |
Event key | "user:abc123" |
$.type |
Event type | "was_created" |
State is accessed differently depending on the operation:
targetfields use state paths (no$prefix):"profile.name"- Predicate
infields use simple state paths:"items" - Predicate comparisons use
$paths against the event:"$.data.status"
{
"if": { "equals": ["$.data.status", "active"] },
"then": [
{"set": {"target": "status", "value": "$.data.status"}}
]
}
Here $.data.status reads from the event, while "status" in the target writes to state.
In implications
In implications, $ resolves against a combined context that includes the trigger event and source aggregate state:
| Path prefix | Resolves against | Example |
|---|---|---|
$.data.* |
Trigger event data | $.data.order_id |
$.metadata.* |
Trigger event metadata | $.metadata.actor |
$.key |
Trigger event key | "order:xyz" |
$.type |
Trigger event type | "was_placed" |
$.state.* |
Source aggregate state | $.state.customer_name |
{
"condition": {"equals": ["$.state.notifications_enabled", true]},
"emit": {
"aggregate_type": "notification",
"id": "$.metadata.actor.id",
"event_type": "was_sent",
"data": {"placed_by": "$.state.user_name"}
}
}
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": "$.state.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
| Syntax | Description | Context |
|---|---|---|
$.data.field |
Event data | Handlers, implications |
$.metadata.timestamp |
Event metadata | Handlers, implications |
$.key |
Event key | Handlers, implications |
$.type |
Event type | Handlers, implications |
$.state.field |
Aggregate state | Implications only |
"field" (no prefix) |
State path | Handler targets, predicate in |
$.path? |
Optional (no-op if missing) | Handlers |
$binding.field |
Variable binding | After let, in map/every/some |
$.path.$entries |
Object to {key, value} array |
Handlers, implications |
{"$merge": [...]} |
Shallow object merge | Handler values |
See also
- Tick reference — using JSONPath in handlers
- Implications reference — using JSONPath in implications