JSONPath

Last updated about 14 hours ago

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
  • target fields 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

Can't find what you need? support@j17.app