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 likeactive,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
- In relative expressions:
- 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
| Operator | Description | Example |
|---|---|---|
+ | Addition | score + bonus, 2 + 3 * 4 |
- | Subtraction | total - discount, 100 - 25 |
* | Multiplication | price * quantity, 3 * 4 |
/ | Division (truncating if both operands are integers; float if either is a float) | total / count, 10 / 3, 10 / 3.0 |
% | Modulo | id % 2, 17 % 5 |
- | Unary minus | -amount, -(x + y) |
What + does with mixed operands
+ is overloaded across these operand-type combinations:
| Left | Right | Result |
|---|---|---|
| Number | Number | Numeric addition |
| String | String | String concatenation |
| String | Number | String concatenation (number stringified) |
| Number | String | String concatenation (number stringified) |
| List | List | List 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
| Operator | Description | Example |
|---|---|---|
> | Greater than | score > 85, #2024-01-15# > #2024-01-10# |
< | Less than | age < 30, created_at < #2024-01-15T10:00:00Z# |
>= | Greater than or equal | points >= 100 |
<= | Less than or equal | count <= 5 |
== | Equal | status == 'active', date == #2024-01-15# |
!= | Not equal | role != '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/DateandDateTime/DateTimecompare chronologically viaDate.compare/2andDateTime.compare/2, never by Erlang's raw struct-key ordering. - Mixed pair: a
Datecompared against aDateTimeis coerced to00:00:00UTC of that day, then compared as twoDateTimes. This applies to ordering (>,<,>=,<=),==/!=, andin/containsmembership. - Strict equality is exempt:
===and!==resolve before any type dispatch, so aDateis never strictly equal to aDateTimeregardless 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
| Operator | Description | Example |
|---|---|---|
AND | Logical AND (case-insensitive) | score > 85 AND age >= 18 |
OR | Logical OR (case-insensitive) | role == 'admin' OR role == 'manager' |
NOT | Logical 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
| Operator | Description | Example |
|---|---|---|
in | Element in collection | role in ['admin', 'manager'] |
contains | Collection contains element | [1, 2, 3] contains 2 |
Builtin Functions
String Functions
| Function | Description | Example |
|---|---|---|
len(string) | String length | len(name) > 3 |
upper(string) | Convert to uppercase | upper(role) == 'ADMIN' |
lower(string) | Convert to lowercase | lower(name) == 'alice' |
trim(string) | Remove surrounding whitespace | len(trim(input)) > 0 |
starts_with(string, prefix) | Prefix test | starts_with(email, 'admin') |
ends_with(string, suffix) | Suffix test | ends_with(file, '.csv') |
substring(string, start[, len]) | Substring by offset | substring(code, 0, 3) == 'ABC' |
index_of(string, sub) | Index of substring, or -1 | index_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
| Function | Description | Example |
|---|---|---|
Math.abs(number) | Absolute value | Math.abs(balance) < 100 |
Math.max(a, b) | Maximum of two numbers | Math.max(score1, score2) > 85 |
Math.min(a, b) | Minimum of two numbers | Math.min(age, 65) >= 18 |
Math.pow(base, exp) | Exponentiation | Math.pow(2, 10) == 1024 |
Math.sqrt(number) | Square root | Math.sqrt(144) == 12 |
Math.floor(number) | Round down | Math.floor(3.9) == 3 |
Math.ceil(number) | Round up | Math.ceil(3.1) == 4 |
Math.round(number) | Round to nearest integer | Math.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
| Function | Description | Example |
|---|---|---|
Date.year(date) | Extract year | Date.year(created_at) == 2024 |
Date.month(date) | Extract month | Date.month(birthday) == 12 |
Date.day(date) | Extract day | Date.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
| Function | Description | Example |
|---|---|---|
concat(list1, list2) | Concatenate two lists | concat([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:
ANDevaluates its left operand. If it is falsy (falseor:undefined), that value is the result and the right side is never evaluated. If the left operand istrue, the right operand's value is the result.ORevaluates its left operand. If it istrue, 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 operand | Result |
|---|---|---|
Arithmetic (+, -, *, /, %, unary -) | rejected | error (type mismatch) |
Comparison, non-strict (>, <, >=, <=, ==, !=) | propagated | :undefined |
Comparison, strict (===, !==) | handled, not rejected | ordinary true/false |
AND, OR | propagated, treated as falsy | short-circuits or falls through |
NOT | rejected | error (type mismatch) |
in, contains | propagated | :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 context | Default | Under 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 +.