The complete Predicator expression language: data types, operators, builtin functions, decompile formatting, and error shapes. For the grammar with precedence, see Architecture; for the tree shape those expressions parse into, see the node inventory in docs/reference/ast.md.

Data Types

  • Numbers: 42, -17 (integers), 3.14, -2.5 (floats)
  • Strings: 'hello', 'world' (single-quoted) or "hello", "world" (double-quoted, with escape sequences)
  • Booleans: true, false (or plain identifiers like active, expired)
  • Dates: #2024-01-15# (ISO 8601 date format)
  • DateTimes: #2024-01-15T10:30:00Z# (ISO 8601 datetime format with timezone)
  • Durations: Natural units for time spans (e.g., 3d, 2h, 15m)
    • In relative expressions: 3d ago, 2w from now, next 1mo, last 1y
    • In arithmetic: #2024-01-10# + 5d, #2024-01-15T10:30:00Z# - 2h
  • Lists: [1, 2, 3], ['admin', 'manager'] (homogeneous collections)
  • Objects: {}, {name: "John", age: 30}, {user: {role: "admin"}} (JavaScript-style object literals)
  • Identifiers: score, user_name, is_active, user.profile.name, user['key'], items[0] (variable references with dot notation and bracket notation for nested data)

Arithmetic Operators

OperatorDescriptionExample
+Additionscore + bonus, 2 + 3 * 4
-Subtractiontotal - discount, 100 - 25
*Multiplicationprice * quantity, 3 * 4
/Division (truncating if both operands are integers; float if either is a float)total / count, 10 / 3, 10 / 3.0
%Moduloid % 2, 17 % 5
-Unary minus-amount, -(x + y)

What + does with mixed operands

+ is overloaded across these operand-type combinations:

LeftRightResult
NumberNumberNumeric addition
StringStringString concatenation
StringNumberString concatenation (number stringified)
NumberStringString concatenation (number stringified)
ListListList concatenation

Every other pairing is a TypeMismatchError, a list against a scalar included: the stringifying coercion applies only when one operand is a string and the other a number.

iex> Predicator.evaluate("'Hello' + ' World'", %{})
{:ok, "Hello World"}

iex> Predicator.evaluate("'Count: ' + 42", %{})
{:ok, "Count: 42"}

iex> Predicator.evaluate("[1, 2] + [3]", %{})
{:ok, [1, 2, 3]}

Comparison Operators

OperatorDescriptionExample
>Greater thanscore > 85, #2024-01-15# > #2024-01-10#
<Less thanage < 30, created_at < #2024-01-15T10:00:00Z#
>=Greater than or equalpoints >= 100
<=Less than or equalcount <= 5
==Equalstatus == 'active', date == #2024-01-15#
!=Not equalrole != 'guest'
===Strict equal (no type coercion)count === 5
!==Strict not equal (no type coercion)count !== "5"

= is assignment, not equality. = is valid only at the start of a statement in the statement grammar - Predicator.parse_program/2

  • with an assignable left side; a bare = in expression position is a parse error naming ==. == and === are the only equality operators. See ADR-0002 for the reasoning.

Comparing dates and datetimes

  • Same type: Date/Date and DateTime/DateTime compare chronologically via Date.compare/2 and DateTime.compare/2, never by Erlang's raw struct-key ordering.
  • Mixed pair: a Date compared against a DateTime is coerced to 00:00:00 UTC of that day, then compared as two DateTimes. This applies to ordering (>, <, >=, <=), ==/!=, and in/contains membership.
  • Strict equality is exempt: === and !== resolve before any type dispatch, so a Date is never strictly equal to a DateTime regardless of the instant either denotes.
  • The anchor is fixed at UTC midnight, with no option to configure it.

Every relative date (3d ago, 2w from now, next 1mo, last 1y) evaluates to a DateTime, so without the coercion a Date context value could never be compared against one.

Logical Operators

OperatorDescriptionExample
ANDLogical AND (case-insensitive)score > 85 AND age >= 18
ORLogical OR (case-insensitive)role == 'admin' OR role == 'manager'
NOTLogical NOT (case-insensitive)NOT expired
!Unary logical negation!expired

! rejects an :undefined operand the same way NOT does - see the "Reject vs. propagate" table below.

Membership Operators

OperatorDescriptionExample
inElement in collectionrole in ['admin', 'manager']
containsCollection contains element[1, 2, 3] contains 2

Builtin Functions

String Functions

FunctionDescriptionExample
len(string)String lengthlen(name) > 3
upper(string)Convert to uppercaseupper(role) == 'ADMIN'
lower(string)Convert to lowercaselower(name) == 'alice'
trim(string)Remove surrounding whitespacelen(trim(input)) > 0
starts_with(string, prefix)Prefix teststarts_with(email, 'admin')
ends_with(string, suffix)Suffix testends_with(file, '.csv')
substring(string, start[, len])Substring by offsetsubstring(code, 0, 3) == 'ABC'
index_of(string, sub)Index of substring, or -1index_of(path, '/') == 0
iex> Predicator.evaluate("len('hello')", %{})
{:ok, 5}

iex> Predicator.evaluate("upper('world')", %{})
{:ok, "WORLD"}

iex> Predicator.evaluate("starts_with('hello world', 'hello')", %{})
{:ok, true}

iex> Predicator.evaluate("ends_with('hello world', 'world')", %{})
{:ok, true}

iex> Predicator.evaluate("substring('hello world', 6)", %{})
{:ok, "world"}

iex> Predicator.evaluate("substring('hello world', 0, 5)", %{})
{:ok, "hello"}

iex> Predicator.evaluate("index_of('hello world', 'world')", %{})
{:ok, 6}

iex> Predicator.evaluate("index_of('hello world', 'nope')", %{})
{:ok, -1}

Numeric Functions

FunctionDescriptionExample
Math.abs(number)Absolute valueMath.abs(balance) < 100
Math.max(a, b)Maximum of two numbersMath.max(score1, score2) > 85
Math.min(a, b)Minimum of two numbersMath.min(age, 65) >= 18
Math.pow(base, exp)ExponentiationMath.pow(2, 10) == 1024
Math.sqrt(number)Square rootMath.sqrt(144) == 12
Math.floor(number)Round downMath.floor(3.9) == 3
Math.ceil(number)Round upMath.ceil(3.1) == 4
Math.round(number)Round to nearest integerMath.round(3.5) == 4
iex> Predicator.evaluate("Math.abs(-5) == 5", %{})
{:ok, true}

iex> Predicator.evaluate("Math.max(1, 2) == 2", %{})
{:ok, true}

iex> Predicator.evaluate("Math.pow(2, 10) == 1024", %{})
{:ok, true}

Date Functions

FunctionDescriptionExample
Date.year(date)Extract yearDate.year(created_at) == 2024
Date.month(date)Extract monthDate.month(birthday) == 12
Date.day(date)Extract dayDate.day(deadline) <= 15
iex> Predicator.evaluate("Date.year(created_at) == 2024", %{"created_at" => ~D[2024-03-15]})
{:ok, true}

Numeric and date functions moved under the Math. and Date. namespaces to avoid colliding with likely user variable names; the unnamespaced string functions predate that convention and were left as-is.

List Functions

FunctionDescriptionExample
concat(list1, list2)Concatenate two listsconcat([1, 2], [3]) == [1, 2, 3]
iex> Predicator.evaluate("concat([1, 2], [3])", %{})
{:ok, [1, 2, 3]}

+ concatenates two lists as well (see "What + does with mixed operands" above). On two lists the two are interchangeable; they differ only in what else each accepts. + also joins strings and numbers, which concat rejects with an EvaluationError. Neither one mixes a list with a scalar - concat([1, 2], 3) is an EvaluationError and [1, 2] + 3 is a TypeMismatchError, so there is no coercion to fall back to.

Decompiling and Formatting Options

Predicator.decompile/2 converts a parsed AST back to source, preserving quote style, with formatting options:

iex> {:ok, ast} = Predicator.parse("score > 85")
iex> Predicator.decompile(ast, spacing: :compact)
"score>85"

iex> {:ok, ast} = Predicator.parse("score > 85")
iex> Predicator.decompile(ast, spacing: :verbose)
"score  >  85"

iex> {:ok, ast} = Predicator.parse("score > 85")
iex> Predicator.decompile(ast, parentheses: :explicit)
"(score > 85)"

Contexts and key normalization

Predicator.Context.new/2 builds a persistent bound context: it merges the builtin function maps with opts[:functions] once, at construction, rather than re-merging on every evaluate/3 call. bind/3 is an O(1) rebind of a single key onto that context's data. assign/3 writes through Predicator.ContextLocation.put/3, the same auto-vivifying algorithm the location expressions guide documents. Predicator.evaluate/3 accepts either a %Context{} or a bare map - a bare map gets a one-shot Context.new/2 internally.

new/2 and bind/3 are the one edge where atom keys and nil values are accepted. Both convert deeply and eagerly - through nested maps and lists - before evaluation ever sees the data: an atom key becomes a string key (the string key wins on collision), and nil becomes :undefined. A Date, DateTime, or any other struct passes through unchanged; only plain maps have their keys touched. The evaluator consults string keys only after that point - it has no atom-key fallback.

Because the function merge happens once at construction rather than per call, reusing a Context across many evaluate/3 calls (e.g. bind/3 in a loop) avoids re-merging the function maps on every evaluation.

Undefined and Sparse Data

Predicator treats missing data as a first-class value, :undefined, rather than raising immediately. What a predicate does with it depends on where the :undefined came from and which operator touches it next. Predicator.Undefined is the one public module that owns the sentinel - value/0, undefined?/1, and to_nil/1/from_nil/1 for a JSON-shaped boundary - and Predicator.Types.undefined?/1 delegates to it.

Where :undefined comes from

  • A bare unbound identifier - a variable not present in the context at all - loads as :undefined.

  • A missing nested path - dot access (user.age) or bracket access (items[99]) on a value that does not have the requested key or index - also evaluates to :undefined, never an error, regardless of whether the root itself is bound.

    iex> Predicator.evaluate("user.age", %{"user" => %{}})
    {:ok, :undefined}

Both cases produce the same value in isolation, but they are not treated the same at the top level - see "Unbound roots vs. missing paths" below.

Mismatched comparisons

1 > "a" is not an error. A type-mismatched pair under a non-strict comparison operator (>, <, >=, <=, ==, !=) evaluates to :undefined:

iex> Predicator.evaluate("1 > 'a'", %{})
{:ok, :undefined}
iex> Predicator.evaluate("true == 1", %{})
{:ok, :undefined}

=== and !== (strict equal/not-equal) never produce :undefined from a type mismatch: they compare without coercion and simply return false or true for two values of different types, :undefined included.

Numbers are the one place the two equality families disagree: integer and float are the same type for ==, so 1 == 1.0 is true, while 1 === 1.0 is false because strict equality does not bridge integer and float.

AND/OR falsiness

At a jump, only false and :undefined count as falsy; true is the only truthy value. AND/OR do not use symmetric three-valued (Kleene) logic - they short-circuit ECMAScript-style, and the two are deliberately asymmetric:

  • AND evaluates its left operand. If it is falsy (false or :undefined), that value is the result and the right side is never evaluated. If the left operand is true, the right operand's value is the result.
  • OR evaluates its left operand. If it is true, that value is the result and the right side is never evaluated. If the left is falsy, the right operand's value is the result.
iex> functions = %{"boom" => {0, fn [], _ctx -> raise "never runs" end}}
iex> Predicator.evaluate("false AND boom()", %{}, functions: functions)
{:ok, false}
iex> Predicator.evaluate("user.missing OR true", %{"user" => %{}})
{:ok, true}
iex> Predicator.evaluate("user.missing AND true", %{"user" => %{}})
{:ok, :undefined}

The asymmetry: an :undefined left operand short-circuits AND to :undefined without touching the right side, while the same :undefined on OR's left falls through and the right side's value wins instead.

NOT is different from both: it requires a boolean operand, so :undefined is a type mismatch under NOT, not a falsy value - see the table below.

Reject vs. propagate, per operator

Some operators treat an :undefined operand as a value that flows through; others treat it as a type error.

Operator family:undefined operandResult
Arithmetic (+, -, *, /, %, unary -)rejectederror (type mismatch)
Comparison, non-strict (>, <, >=, <=, ==, !=)propagated:undefined
Comparison, strict (===, !==)handled, not rejectedordinary true/false
AND, ORpropagated, treated as falsyshort-circuits or falls through
NOTrejectederror (type mismatch)
in, containspropagated:undefined

Dot access, bracket access, and function arguments are not in this table because they don't reject or propagate an incoming :undefined the way an operator does: a missing key or index always produces :undefined rather than erroring (see "Where :undefined comes from" above), and a function receives whatever :undefined-or-not value its arguments evaluated to - a custom function decides for itself what to do with one.

Unbound roots vs. missing paths, and on_unbound

Not every :undefined stays silent at the top level. Predicator.evaluate/3 distinguishes an :undefined that traces back to a genuinely unbound root variable from one that is just a legitimately absent nested value, and reports the former as an error even without opting into anything:

iex> {:error, err} = Predicator.evaluate("missing", %{})
iex> err.variable
"missing"

iex> {:error, err} = Predicator.evaluate("missing == 5", %{})
iex> err.variable
"missing"

iex> {:error, err} = Predicator.evaluate("not missing", %{})
iex> err.variable
"missing"

iex> Predicator.evaluate("user.age", %{"user" => %{}})
{:ok, :undefined}

The rule: if the final result is :undefined, or an operator rejected an :undefined operand with a type-mismatch error, and that :undefined traces back to a root variable this evaluation actually loaded and did not find bound, Predicator reports Predicator.Errors.UndefinedVariableError naming that variable instead of the bare :undefined or the type-mismatch error. A missing nested path (user.age where user has no age) never triggers this - only a bare unbound root does.

Short-circuiting still wins over this rule, and so does an operator that absorbs the :undefined into a defined result - neither reports an error:

iex> Predicator.evaluate("missing OR true", %{})
{:ok, true}
iex> Predicator.evaluate("false AND missing", %{})
{:ok, false}
iex> Predicator.evaluate("[missing]", %{})
{:ok, [:undefined]}

Set on_unbound: :error to make every load of an unbound root fail immediately - including the cases just above, which the default behavior would have absorbed into a defined result:

iex> {:error, err} = Predicator.evaluate("missing OR true", %{}, on_unbound: :error)
iex> err.variable
"missing"

on_unbound accepts :undefined (the default) or :error. It is a keyword option on Predicator.evaluate/3 and Predicator.Context.new/2 (which validates it strictly - any other value raises ArgumentError). It only affects a root load: a missing nested path stays :undefined under either policy, since access and bracket_access never consult on_unbound - and it never fires on a load a short-circuited branch skipped.

What actually changes under :error is narrower than it looks, because an unbound root already errors under the default whenever its :undefined reaches the result or is rejected by an operator. The policy's own cases are the ones where the default absorbs the sentinel into a defined result:

Expression, empty contextDefaultUnder on_unbound: :error
missing OR true{:ok, true}{:error, ...}
[missing]{:ok, [:undefined]}{:error, ...}
{'a': missing}{:ok, %{"a" => :undefined}}{:error, ...}
missing, missing == 5, not missing, missing + 1{:error, ...}same
false AND missing, true OR missing{:ok, false} / {:ok, true}unchanged - never loaded

Error Shapes

Predicator returns errors as structs under Predicator.Errors, never as bare strings, and never raises at a leaf:

iex> {:error, err} = Predicator.evaluate("score >> 85", %{})
iex> {err.__struct__, err.position}
{Predicator.Errors.ParseError, {1, 8}}
iex> err.message
"Expected number, string, boolean, date, datetime, identifier, function call, list, object, or '(' but found '>'"

iex> {:error, err} = Predicator.evaluate("score AND", %{})
iex> err.position
{1, 10}

A function that errors mid-evaluation surfaces as an EvaluationError:

iex> custom_functions = %{"divide" => {2, fn [a, b], _ctx ->
...>   if b == 0, do: {:error, "Division by zero"}, else: {:ok, a / b}
...> end}}
iex> {:error, err} = Predicator.evaluate("divide(10, 0)", %{}, functions: custom_functions)
iex> {err.__struct__, err.message}
{Predicator.Errors.EvaluationError, "Division by zero"}

See the location expressions guide for Predicator.Errors.LocationError, the third error struct.

Positions and spans on errors

EvaluationError, TypeMismatchError, and UndefinedVariableError each carry an optional :position and an optional :span. Under the default options only :position is set; passing spans: true to evaluate/3 also sets :span, and in that case :position is set to the span's start, so a caller that only ever reads :position keeps working unchanged. The rendered message string is the same either way. See docs/reference/ast.md for what a span covers.

An unbound variable's error is positioned at the variable's own load, not at the operator that rejected its :undefined value:

iex> {:error, err} = Predicator.evaluate("unbound + 1", %{})
iex> err.position
{1, 1}

{1, 1} is the position of unbound, not of +.