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:

  • target fields use state paths (no $ prefix): "profile.name"
  • Predicate in fields 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

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