@sanity/json-match

A modern, lightweight implementation of the JSONMatch path expression language made for low-level use in other libraries such as the Sanity App SDK.
Table of Contents
Installation
npm install @sanity/json-match
Core Concepts
What is JSONMatch?
JSONMatch is a query language designed to select one or more sub-values within a JSON document.
Here's a quick example:
import {jsonMatch} from '@sanity/json-match'
const data = {
users: [
{name: 'Alice', _key: 'alice', age: 25, active: true},
{name: 'Bob', _key: 'bob', age: 30, active: false},
{name: 'Carol', _key: 'carol', age: 35, active: true},
],
}
const expression = 'users[active == true]'
const matches = jsonMatch(data, expression)
for (const match of matches) {
console.log('Matched Value:', match.value)
console.log('Path to Value:', match.path)
}
To use JSONMatch you:
- Write a path expression (e.g.
users[active == true]) that describes where to search (e.g. users) and what constraints must be satisfied (e.g. [active == true]). To use a path expression, you…
- Evaluate it against a JSON value. The evaluator then searches the document and returns all the matches that satisfy your expression.
For each match found, you get back a MatchEntry object:
export interface MatchEntry {
value: unknown
path: SingleValuePath
}
export type IndexTuple = [number | '', number | '']
export type KeyedSegment = {_key: string}
export type PathSegment = string | number | KeyedSegment | IndexTuple
export type Path = PathSegment[]
export type SingleValuePath = Exclude<PathSegment, IndexTuple>[]
export function jsonMatch(value, pathExpr): Generator<MatchEntry>
[!TIP]
The Path format is a common representation in many other Sanity libraries making it easy to integrate with other Sanity tools.
This path type can be turned back into a string with stringifyPath if desired. See the path manipulation section for more path utilities.
Lazy evaluation
The jsonMatch function is lazy, meaning it returns a generator that yields matches one by one, only when you ask for them. It doesn't compute all possible matches upfront.
This design offers a significant performance advantage, especially when you're working with large datasets and only need the first few matches. For example, if you're looking for the first active item in an array of thousands of elements, jsonMatch will stop searching as soon as it finds it.
import {jsonMatch} from '@sanity/json-match'
const data = {
items: Array.from({length: 1000}, (_, i) => ({
id: i,
active: i === 500,
})),
}
const generator = jsonMatch(data, 'items[active == true]')
const firstMatch = generator.next().value
console.log('Found:', firstMatch.value)
This makes the library suitable for performance-critical tasks where you need to find a single value quickly.
Handling undefined values
jsonMatch yields matches for paths that resolve to undefined values, even when traversing through invalid intermediate values. This exhaustive path traversal behavior is designed to support both reading and writing operations in the Sanity ecosystem.
import {jsonMatch} from '@sanity/json-match'
const data = {
user: {
name: 'Alice',
},
posts: [
{title: 'First Post'},
],
version: '1.0.0',
}
const emailMatches = Array.from(jsonMatch(data, 'user.email'))
console.log(emailMatches)
const missingPostMatches = Array.from(jsonMatch(data, 'posts[1].title'))
console.log(missingPostMatches)
const invalidTraversal = Array.from(jsonMatch(data, 'version.major.patch'))
console.log(invalidTraversal)
const mixedArray = {
items: ['string', {name: 'Alice'}, null, 42],
}
const mixedResults = Array.from(jsonMatch(mixedArray, 'items.name'))
console.log(mixedResults)
const pathsForSetting = Array.from(jsonMatch(data, 'user.profile.settings.theme')).map(
(m) => m.path,
)
console.log(pathsForSetting)
Why this matters:
- For reading: You can distinguish between non-existent paths and paths that exist but contain
undefined
- For writing: You get the exact path information needed to set values at locations that don't currently exist, even through multiple levels of missing intermediate values
- For consistency: The same path expression works for both reading existing values and determining where new values can be written
- For completeness: Arrays with mixed types (objects, primitives, null) yield results for all elements, with
undefined for non-objects
How exhaustive traversal works:
- Property access (
obj.prop): Always yields a result, even if obj is not an object
- Array indexing (
arr[0]): Always yields a result, even if arr is not an array or index is out of bounds
- Deep paths (
a.b.c.d): Continues evaluating the entire path even if intermediate segments are invalid
- Array iteration (
arr.prop): Applies property access to every array element, yielding undefined for non-objects
[!NOTE]
This exhaustive behavior only applies to property access and array indexing. Constraint-based filtering (like [active == true]) will still skip items that don't match the constraint, and existence checks (like [email?]) will not match undefined values.
Special _key handling
In Sanity documents, it's good practice to add a unique _key property to objects inside an array. This gives each object a stable identifier that doesn't change even if the array is reordered.
jsonMatch has special first-class support for _keys to improve both path resilience and performance.
- When a matched object has a
_key, the returned path will use the key instead of the array index. This creates a path that remains valid even if other items are added, removed, or reordered.
- When you filter an array by
_key (e.g., items[_key == "b"]), this library creates and caches a lookup of indexes so subsequent evaluations of the same path expression will be cheap. See getIndexForKey for more info.
[!WARNING]
For these optimizations to work reliably, _key values are assumed to be unique within any given array. If an array contains objects with duplicate _keys, the behavior is undefined, as the evaluator will typically match only the first object it finds.
Path manipulation
This library ships with some helper functions to help modify and normalize paths. These utilities allow you to programmatically construct, deconstruct, and analyze paths in a consistent way, regardless of their original format (string, array, or AST).
Here are a few common use cases:
import {joinPaths, slicePath, getPathDepth, stringifyPath, type Path} from '@sanity/json-match'
const originalPath = 'users[0].posts[_key=="abc"].title'
const parentFromString = slicePath(originalPath, 0, -1)
const originalPathArray: Path = ['users', 0, 'posts', {_key: 'abc'}, 'title']
const parentFromArray = slicePath(originalPathArray, 0, -1)
const newPath = joinPaths(parentFromArray, 'lastModified')
const pathString = stringifyPath(originalPathArray)
console.log(pathString === originalPath)
const depth = getPathDepth(originalPath)
JSONMatch Language Reference
See our official documentation for JSONMatch.
import {jsonMatch} from '@sanity/json-match'
const data = {
name: 'fred',
friends: [
{name: 'mork', age: 40, favoriteColor: 'red'},
{name: 'mindy', age: 32, favoriteColor: 'blue'},
{name: 'franklin', favoriteColor: 'yellow'},
{name: 'bob', favoriteColor: 'green'},
{name: 'alice', favoriteColor: 'blue'},
],
roles: ['admin', 'owner'],
contactInfo: {
streetAddress: '42 Mountain Road',
state: {
shortName: 'WY',
longName: 'Wyoming',
},
},
}
function evaluate(expression) {
const matches = jsonMatch(data, expression)
const values = Array.from(matches).map((match) => match.value)
console.log(values)
}
evaluate('name')
evaluate('friends[*].name')
evaluate('friends[age > 35].name')
evaluate('friends[age > 30, favoriteColor == "blue"].name')
evaluate('friends[age?].age')
evaluate('friends[0].name')
evaluate('friends[1:3].name')
evaluate('friends[0, 2:3].name')
evaluate('contactInfo.state.shortName')
evaluate('contactInfo.state[shortName, longName]')
evaluate('friends.age[@ > 35]')
evaluate('roles')
evaluate('roles[*]')
evaluate('roles[0]')
evaluate('roles[-1]')
evaluate('contactInfo..shortName')
evaluate('[contactInfo.state.shortName, roles]')
Full EBNF Grammar
Expression ::=
| String // literal
| Number // literal
| Boolean // literal
| Null // literal
| Path
Path ::=
| '.' Path // implicit 'this' descent
| '..' Path // implicit 'this' recursive descent
| PathSegment
| PathSegment '.' Path
| PathSegment '..' Path
PathSegment ::=
| This
| Identifier
| Wildcard
| Subscript
Subscript ::=
| '[' SubscriptContent ']'
SubscriptContent ::=
| SubscriptElement
| SubscriptElement ',' SubscriptContent
SubscriptElement ::=
| Slice
| Existence
| Comparison
| Expression
Slice ::=
| Number ':' Number
| Number ':'
| ':' Number
| ':'
Existence ::=
| Path '?'
Comparison ::=
| Expression ComparisonOperator Expression
ComparisonOperator ::=
| '=='
| '!='
| '<'
| '<='
| '>'
| '>='
This ::=
| '@'
| '$'
Identifier ::=
| UnquotedIdentifier
| QuotedIdentifier
Wildcard ::=
| '*'
Number ::=
| '-'? [0-9]+ ('.' [0-9]+)?
Boolean ::=
| 'true'
| 'false'
Null ::=
| 'null'
String ::=
| '"' StringContent '"'
StringContent ::=
| (EscapeSequence | [^"\\])*
EscapeSequence ::=
| '\\' ['"\\\/bfnrt]
| '\\u' HexDigit HexDigit HexDigit HexDigit
HexDigit ::=
| [0-9a-fA-F]
UnquotedIdentifier ::=
| [a-zA-Z_$][a-zA-Z0-9_$]*
QuotedIdentifier ::=
| "'" QuotedIdentifierContent "'"
QuotedIdentifierContent ::=
| (EscapeSequence | [^'\\])*
API Reference
jsonMatch(value, path, basePath?)
The main function for evaluating JSONMatch expressions.
import {jsonMatch} from '@sanity/json-match'
const data = {users: [{name: 'Alice', age: 25}]}
const matches = Array.from(jsonMatch(data, 'users[*].name'))
const nestedMatches = Array.from(jsonMatch(data.users, '[*].name', ['users']))
stringifyPath(path)
Convert various path formats to their string representation.
This function can handle JSONMatch AST nodes, Path arrays, and string expressions.
It's useful for normalizing different path formats into a consistent string format.
import {parsePath, stringifyPath} from '@sanity/json-match'
const ast = parsePath('users[age > 21].name')
const str = stringifyPath(ast)
const pathArr = ['users', 0, {_key: 'profile'}, 'email']
const pathStr = stringifyPath(pathArr)
const existing = 'items[*].name'
const result = stringifyPath(existing)
const normalized = stringifyPath(parsePath(' users [ age > 21 ] . name '))
console.log(normalized)
getIndexForKey(array, key)
Efficiently find the array index for objects with _key properties (Sanity's keyed arrays).
import {getIndexForKey} from '@sanity/json-match'
const keyedArray = [
{_key: 'item1', name: 'First'},
{_key: 'item2', name: 'Second'},
{_key: 'item3', name: 'Third'},
]
const index = getIndexForKey(keyedArray, 'item2')
console.log(keyedArray[index])
const index2 = getIndexForKey(keyedArray, 'item3')
getPathDepth(path)
Calculates the number of segments in a path.
import {getPathDepth} from '@sanity/json-match'
getPathDepth('user.profile.email')
getPathDepth('items[0].name')
getPathDepth(['users', {_key: 'alice'}])
joinPaths(base, path)
Joins two path segments into a single path string.
import {joinPaths} from '@sanity/json-match'
joinPaths('user', 'profile')
let path = 'data'
path = joinPaths(path, 'users')
path = joinPaths(path, '[0]')
parsePath(input)
Parse various path formats into a standardized AST.
import {parsePath} from '@sanity/json-match'
parsePath('user.profile.email')
parsePath(['users', 0, {_key: 'profile'}, 'email'])
const ast = parsePath('items[*]')
parsePath(ast) === ast
slicePath(path, start?, end?)
Extracts a section of a path, similar to Array.prototype.slice.
import {slicePath} from '@sanity/json-match'
slicePath('a.b.c.d.e', 1, 4)
slicePath('user.profile.email', 0, -1)
slicePath('items[0].name', -1)
LICENSE
MIT License - see LICENSE file for details.