jilt
v0.1.0
Published
JSON object filtering with a small expression language
Maintainers
Readme
jilt - JSON object filtering
Toolkit for turning human-readable/human-writable filter expressions into executable functions which check if a JSON object matches.
Vibe coded by codex using model gpt-5.2-codex.
Quickstart
import jilt from '@walkable/jilt'
const expr1 = jilt.parse('color = "red" and weight < 3.2 and size.x > 1')
console.log(expr1.tree())
// ['and', ['eq', ['field', 'color'], ['literal', 'red']], ...]
const func1 = expr1.compile()
console.log(func1({color: 'red', weight: 3, size: {x: 2, y: 2}}))
// => true
console.log(func1({color: 'red', weight: 3, size: {x: 1, y: 2}}))
// => falseCLI
# Test an expression against JSON input
echo '{"color": "red", "weight": 3}' | jilt 'color = "red" and weight < 5'
# => true
# Parse and display the AST
jilt --tree 'color = "red" and weight < 5'
# => ['and', ['eq', ['field', 'color'], ['literal', 'red']], ['lt', ['field', 'weight'], ['literal', 5]]]
# Interactive mode - enter JSON objects, see if they match
jilt -i 'price < 100 and inStock = true'
> {"price": 50, "inStock": true}
true
> {"price": 150, "inStock": true}
falseExpression Syntax
Literals
| Type | Examples |
|------|----------|
| String | "hello", 'hello' |
| Number | 42, 3.14, -10, 2.5e10 |
| Boolean | true, false |
| Null | null |
Field Access
Access object properties using dot notation or bracket notation:
name # top-level field
user.name # nested field
user.address.city # deeply nested
items[0] # array index
items[0].name # array index then field
user["first name"] # bracket notation for special charactersComparison Operators
| Operator | Aliases | Description |
|----------|---------|-------------|
| = | ==, eq | Equal |
| != | <>, ne | Not equal |
| < | lt | Less than |
| <= | le | Less than or equal |
| > | gt | Greater than |
| >= | ge | Greater than or equal |
Logical Operators
| Operator | Description |
|----------|-------------|
| and | Logical AND |
| or | Logical OR |
| not | Logical NOT |
Parentheses control precedence:
(color = "red" or color = "blue") and weight < 5
not (status = "deleted")Arithmetic Operators
| Operator | Description |
|----------|-------------|
| + | Addition |
| - | Subtraction |
| * | Multiplication |
| / | Division |
| % | Modulo |
price * quantity > 100
(width + height) / 2 >= 10
count % 2 = 0String Functions
| Function | Description | Example |
|----------|-------------|---------|
| length(s) | String length | length(name) > 5 |
| upper(s) | Uppercase | upper(status) = "ACTIVE" |
| lower(s) | Lowercase | lower(email) = "[email protected]" |
| trim(s) | Remove leading/trailing whitespace | trim(input) != "" |
| contains(s, sub) | Check if string contains substring | contains(title, "urgent") |
| startsWith(s, prefix) | Check if string starts with prefix | startsWith(id, "usr_") |
| endsWith(s, suffix) | Check if string ends with suffix | endsWith(filename, ".json") |
| substr(s, start) | Substring from start index | substr(code, 0, 2) = "US" |
| substr(s, start, len) | Substring with length | substr(code, 0, 2) = "US" |
| concat(s1, s2, ...) | Concatenate strings | concat(first, " ", last) |
| replace(s, old, new) | Replace first occurrence | replace(path, "/old/", "/new/") |
| split(s, delim) | Split string into array | length(split(tags, ",")) > 2 |
Pattern Matching
| Function | Description | Example |
|----------|-------------|---------|
| regex(s, pattern) | Match against regular expression | regex(email, "^[a-z]+@[a-z]+\\.[a-z]+$") |
| like(s, pattern) | SQL-style wildcards: % = any chars, _ = single char | like(email, "%@gmail.com") |
| glob(s, pattern) | Shell-style wildcards: * = any chars, ? = single char | glob(filename, "*.json") |
Pattern matching examples:
# SQL-style LIKE
like(name, "John%") # starts with "John"
like(code, "A_B") # "A" + any single char + "B"
like(email, "%@%.com") # contains "@" and ends with ".com"
# Shell-style glob
glob(file, "*.txt") # ends with ".txt"
glob(path, "/home/*/docs") # any user's docs folder
glob(name, "test?.js") # test1.js, testA.js, etc.
# Regular expressions
regex(phone, "^\\d{3}-\\d{4}$") # 123-4567
regex(id, "^[A-Z]{2}\\d{6}$") # US123456
regex(version, "^v\\d+\\.\\d+\\.\\d+$") # v1.2.3Path Functions
| Function | Description | Example |
|----------|-------------|---------|
| basename(path) | Get filename from path | basename("/foo/bar.txt") = "bar.txt" |
| dirname(path) | Get directory from path | dirname("/foo/bar.txt") = "/foo" |
| ext(path) | Get file extension (without dot) | ext("/foo/bar.txt") = "txt" |
Path function examples:
ext(filename) = "json"
basename(path) = "config.yaml"
dirname(file) = "/etc/myapp"
startsWith(dirname(path), "/home/admin")Array Functions
| Function | Description | Example |
|----------|-------------|---------|
| length(arr) | Array length | length(items) > 0 |
| contains(arr, val) | Check if array contains value | contains(tags, "featured") |
| first(arr) | First element | first(scores) > 90 |
| last(arr) | Last element | last(events).type = "complete" |
| at(arr, index) | Element at index | at(items, 2).price < 10 |
| sum(arr) | Sum of numeric array | sum(prices) < 1000 |
| avg(arr) | Average of numeric array | avg(scores) >= 70 |
| min(arr) | Minimum value | min(bids) > 100 |
| max(arr) | Maximum value | max(temperatures) < 30 |
| join(arr, delim) | Join array into string | join(names, ", ") |
| reverse(arr) | Reverse array | first(reverse(items)) |
| sort(arr) | Sort array ascending | first(sort(scores)) |
| unique(arr) | Remove duplicates | length(unique(tags)) = length(tags) |
Array Predicates
Test conditions across array elements using @ to reference the current element:
| Function | Description | Example |
|----------|-------------|---------|
| any(arr, pred) | True if any element matches | any(items, @.price > 100) |
| all(arr, pred) | True if all elements match | all(scores, @ >= 60) |
| none(arr, pred) | True if no elements match | none(items, @.status = "error") |
| count(arr, pred) | Count matching elements | count(items, @.inStock) > 5 |
| filter(arr, pred) | Filter to matching elements | length(filter(users, @.active)) > 0 |
| map(arr, expr) | Transform elements | sum(map(items, @.price * @.qty)) |
Type Checking
| Function | Description | Example |
|----------|-------------|---------|
| type(val) | Get type as string | type(data) = "array" |
| isString(val) | Check if string | isString(name) |
| isNumber(val) | Check if number | isNumber(count) |
| isBool(val) | Check if boolean | isBool(enabled) |
| isNull(val) | Check if null | isNull(deletedAt) |
| isArray(val) | Check if array | isArray(tags) |
| isObject(val) | Check if object | isObject(metadata) |
Conditional
if(condition, thenValue, elseValue)Example:
if(premium, price * 0.9, price) < 50Null Handling
| Operator | Description | Example |
|----------|-------------|---------|
| ?? | Null coalescing | (nickname ?? name) = "Admin" |
| ?. | Optional chaining | user?.profile?.avatar != null |
Operator Precedence
From highest to lowest:
- Parentheses
() - Function calls, field access
.,[],?. - Unary
not,- - Multiplicative
*,/,% - Additive
+,- - Comparison
=,!=,<,<=,>,>= - Logical
and - Logical
or - Null coalescing
??
Complex Examples
Computed string comparison
Check if concatenated name fields match the full name:
concat(firstName, " ", lastName) = fullNameWith case-insensitive comparison:
lower(concat(firstName, " ", lastName)) = lower(fullName)Validating data consistency
Check that line items sum to the total:
sum(map(items, @.price * @.quantity)) = orderTotalVerify no duplicate IDs in an array:
length(unique(map(items, @.id))) = length(items)Complex filtering conditions
Find orders with at least one high-value item that's in stock:
any(items, @.price > 100 and @.inStock = true) and status != "cancelled"Filter files by extension and location:
ext(path) = "log" and startsWith(dirname(path), "/var/log")Match versioned config files:
glob(filename, "config-v*.json") and dirname(path) = "/etc/myapp"Conditional pricing logic
Apply tiered discount based on quantity:
price * quantity * if(quantity > 100, 0.8, if(quantity > 50, 0.9, 1.0)) < budgetWorking with nested data
Check that all addresses in a user's profile have a valid zip code:
all(user.addresses, regex(@.zipCode, "^\\d{5}(-\\d{4})?$"))Find users who have made a purchase in a specific category:
any(purchases, @.category = "electronics" and @.amount > 500)Null-safe operations
Handle potentially missing nested fields:
(user?.profile?.email ?? user?.email ?? "unknown") != "unknown"Check optional array:
length(tags ?? []) > 0AST Format
The parsed expression is represented as a JSON-compatible tree:
// Input: 'color = "red" and price < 100'
// AST:
['and',
['eq', ['field', 'color'], ['literal', 'red']],
['lt', ['field', 'price'], ['literal', 100]]
]Node Types
| Node | Format |
|------|--------|
| Literal | ['literal', value] |
| Field access | ['field', 'name'] or ['field', 'parent', 'child'] |
| Comparison | ['eq' \| 'ne' \| 'lt' \| 'le' \| 'gt' \| 'ge', left, right] |
| Logical | ['and' \| 'or', ...children] or ['not', child] |
| Arithmetic | ['add' \| 'sub' \| 'mul' \| 'div' \| 'mod', left, right] |
| Function call | ['call', 'funcName', ...args] |
| Array index | ['index', array, indexExpr] |
| Conditional | ['if', condition, thenExpr, elseExpr] |
| Null coalesce | ['coalesce', left, right] |
| Optional chain | ['optfield', base, 'fieldName'] |
JavaScript API
jilt.parse(expression: string): Expression
Parse an expression string into an Expression object.
const expr = jilt.parse('status = "active"')Expression.tree(): Array
Get the AST representation.
expr.tree()
// => ['eq', ['field', 'status'], ['literal', 'active']]Expression.compile(): (obj: any) => boolean
Compile to an executable function.
const match = expr.compile()
match({status: 'active'}) // => true
match({status: 'deleted'}) // => falseExpression.toString(): string
Convert back to expression string (normalized form).
jilt.parse('x=1 AND y = 2').toString()
// => 'x = 1 and y = 2'jilt.evaluate(expression: string, obj: any): boolean
Parse, compile, and evaluate in one step.
jilt.evaluate('price < 100', {price: 50})
// => truejilt.evaluateConstant(node: ExprNode, options?): unknown
Evaluate an AST node as a constant expression (no input object needed).
const expr = jilt.parse('2 + 3 * 4')
jilt.evaluateConstant(expr.tree())
// => 14Error Handling
Parse errors include position information:
try {
jilt.parse('name = ')
} catch (e) {
console.log(e.message)
// => "Unexpected end of expression at position 7"
console.log(e.position)
// => 7
}