Elex
Elex is a powerful expression language library for Elixir that provides parsing, validation, and evaluation of mathematical and logical expressions.
Documentation
Full guides are available on hexdocs.pm:
Features
- Arithmetic Operations:
+,-,*,/,%(modulo), unary- - Comparison Operators:
<,>,<=,>=,==,!=(numbers and strings) - Logical Operations:
and,or,not(with short-circuit evaluation) - Literals: Decimal numbers, booleans (
true/false,yes/no), strings, andnull - Variables: Dynamic variable substitution
- Functions: Built-in math, string, and utility functions (see Functions) and custom functions via
Elex.Function - Type System: Static type checking and validation
- Decimal Precision: Uses
Decimalfor accurate arithmetic - Expression Inversion: Solve for variables in simple expressions
- Ash Integration: Optional Ash validation for resource attributes
Installation
Add elex to your list of dependencies in mix.exs:
def deps do
[
{:elex, "~> 0.2.0"}
]
end
Quick Start
# Create a context with variables
context = Elex.new_context()
|> Elex.add_variable("price", 100)
|> Elex.add_variable("tax_rate", 0.08)
# Evaluate an expression
{:ok, result} = Elex.evaluate("price * (1 + tax_rate)", context)
# result => Decimal.new("108")
# Validate expression type
{:ok, :decimal} = Elex.validate("price + 10", context)
{:ok, :boolean} = Elex.validate("price > 50", context)
# Extract variables from an expression
{:ok, ["price", "quantity"]} = Elex.extract_variables("price * quantity")
Expression Syntax
Literals
# Numbers (decimal)
Elex.evaluate("42", Elex.new_context())
Elex.evaluate("3.14", Elex.new_context())
Elex.evaluate("-5.5", Elex.new_context())
# Booleans
Elex.evaluate("true", Elex.new_context())
Elex.evaluate("false", Elex.new_context())
Elex.evaluate("yes", Elex.new_context()) # alias for true
Elex.evaluate("no", Elex.new_context()) # alias for false
# Strings
Elex.evaluate("\"hello\"", Elex.new_context())
# Null
Elex.evaluate("null", Elex.new_context())
null compares equal only to null or nil variables (null == null, x == null when x is nil). It cannot be compared to numbers, booleans, or strings.
Variables
Variable names must start with a lowercase letter and may contain letters, digits, and underscores. The words and, or, not, null, true, false, yes, and no are reserved and cannot be used as variable names.
context =
Elex.new_context()
|> Elex.add_variable("price", 100)
|> Elex.add_variable("tax_rate", 0.08)
{:ok, result} = Elex.evaluate("price * (1 + tax_rate)", context)
# result => #Decimal<108>
Arithmetic
context = Elex.new_context()
{:ok, result} = Elex.evaluate("10 + 5", context) # => #Decimal<15>
{:ok, result} = Elex.evaluate("10 - 5", context) # => #Decimal<5>
{:ok, result} = Elex.evaluate("10 * 5", context) # => #Decimal<50>
{:ok, result} = Elex.evaluate("10 / 5", context) # => #Decimal<2>
{:ok, result} = Elex.evaluate("10 % 3", context) # => #Decimal<1>
{:ok, result} = Elex.evaluate("2 + 3 * 4", context) # => #Decimal<14> (respects precedence)
{:ok, result} = Elex.evaluate("-5", context) # => #Decimal<-5> (unary minus)
{:ok, result} = Elex.evaluate("-(1 + 2)", context) # => #Decimal<-3>
Note:
Elex.evaluate/2returns{:ok, result}on success or{:error, reason}on failure. Arithmetic operations useDecimaland returnDecimalvalues. The%operator has the same precedence as*and/.
Comparisons
{:ok, true} = Elex.evaluate("10 > 5", Elex.new_context())
{:ok, false} = Elex.evaluate("10 < 5", Elex.new_context())
{:ok, true} = Elex.evaluate("10 >= 10", Elex.new_context())
{:ok, false} = Elex.evaluate("10 <= 5", Elex.new_context())
{:ok, true} = Elex.evaluate("10 == 10", Elex.new_context())
{:ok, true} = Elex.evaluate("10 != 5", Elex.new_context())
# String ordering (lexicographic)
{:ok, true} = Elex.evaluate(~s["a" < "b"], Elex.new_context())
{:ok, true} = Elex.evaluate(~s["b" >= "a"], Elex.new_context())
Comparison operands must have the same type (decimal, boolean, string, or null).
Logical Operations
{:ok, false} = Elex.evaluate("true and false", Elex.new_context())
{:ok, true} = Elex.evaluate("true or false", Elex.new_context())
{:ok, false} = Elex.evaluate("not true", Elex.new_context())
and, or, and if(condition, a, b) use short-circuit evaluation: the right-hand operand (or unselected branch) is not evaluated when its result cannot change the outcome. This avoids errors such as division by zero in guard expressions:
{:ok, false} = Elex.evaluate("false and (1 / 0 > 0)", Elex.new_context())
{:ok, true} = Elex.evaluate("true or (1 / 0 > 0)", Elex.new_context())
{:ok, result} = Elex.evaluate("if(false, 1 / 0, 2)", Elex.new_context()) # => #Decimal<2>
Functions
Math
| Function | Description |
|---|---|
abs(x) | Absolute value |
ceil(x), floor(x), round(x) | Rounding |
sqrt(x) | Square root |
pow(base, exp) | Exponentiation |
rem(a, b) | Remainder (sign follows the dividend; same as %) |
mod(a, b) | Floored modulo (sign follows the divisor) |
max(a, b, …), min(a, b, …) | Largest or smallest of two or more numbers (variadic) |
clamp(x, min, max) | Clamp x to an inclusive range |
between(x, low, high) | true when x is in the inclusive range |
pi() | Mathematical constant π |
if(cond, a, b) | Conditional (short-circuits; branches must share a type) |
context = Elex.new_context()
{:ok, result} = Elex.evaluate("max(10, 20)", context) # => #Decimal<20>
{:ok, result} = Elex.evaluate("max(3, 7, 9)", context) # => #Decimal<9>
{:ok, result} = Elex.evaluate("min(10, 20)", context) # => #Decimal<10>
{:ok, result} = Elex.evaluate("abs(-5)", context) # => #Decimal<5>
{:ok, result} = Elex.evaluate("pow(2, 3)", context) # => #Decimal<8>
{:ok, result} = Elex.evaluate("mod(10, 3)", context) # => #Decimal<1>
{:ok, result} = Elex.evaluate("clamp(15, 0, 10)", context) # => #Decimal<10>
{:ok, result} = Elex.evaluate("between(5, 0, 10)", context) # => true
{:ok, result} = Elex.evaluate("ceil(3.2)", context) # => #Decimal<4>
{:ok, result} = Elex.evaluate("floor(3.8)", context) # => #Decimal<3>
{:ok, result} = Elex.evaluate("round(3.5)", context) # => #Decimal<4>
{:ok, result} = Elex.evaluate("sqrt(16)", context) # => #Decimal<4>
{:ok, result} = Elex.evaluate("rem(10, 3)", context) # => #Decimal<1>
{:ok, result} = Elex.evaluate("pi()", context) # => #Decimal<3.14159…>
{:ok, result} = Elex.evaluate("if(10 > 5, 1, 0)", context) # => #Decimal<1>
Strings
| Function | Description |
|---|---|
concat(a, b) | Concatenate two strings |
length(s) | String length (character count) |
contains(haystack, needle) | Substring search |
starts_with(s, prefix), ends_with(s, suffix) | Prefix/suffix test |
lower(s), upper(s), trim(s) | Case and whitespace transforms |
coalesce(a, b, …) | First non-null argument (variadic; short-circuits) |
context = Elex.new_context()
{:ok, result} = Elex.evaluate(~s[concat("hello", " world")], context) # => "hello world"
{:ok, result} = Elex.evaluate(~s[length("abc")], context) # => #Decimal<3>
{:ok, result} = Elex.evaluate(~s[contains("hello", "ell")], context) # => true
{:ok, result} = Elex.evaluate(~s[starts_with("hello", "he")], context) # => true
{:ok, result} = Elex.evaluate(~s[ends_with("hello", "lo")], context) # => true
{:ok, result} = Elex.evaluate(~s[lower("ABC")], context) # => "abc"
{:ok, result} = Elex.evaluate(~s[upper("abc")], context) # => "ABC"
{:ok, result} = Elex.evaluate(~s[trim(" x ")], context) # => "x"
{:ok, result} = Elex.evaluate("coalesce(null, 5)", context) # => #Decimal<5>
Ash Integration
Elex provides an optional Ash validation for validating expressions in resource attributes:
defmodule MyApp.Resource do
use Ash.Resource
attributes do
attribute :formula, :string do
allow_nil? false
end
end
validations do
validate Elex.AshValidation,
attribute: :formula,
context: Elex.new_context(),
expected_type: :decimal
end
end
The expected_type option accepts :decimal, :boolean, or :string. Use add_value_type_from_attribute to inject a value variable typed from another attribute — useful when validating formulas that reference the current value.
Expression Inversion
Elex can invert simple arithmetic expressions to solve for a variable:
alias Elex.{Parser, Inverter}
context = Elex.new_context()
{:ok, ast, _type} = Parser.parse("value * 2 + 5", context, validate: false)
{:ok, inverted_ast} = Inverter.invert(ast, "value")
# The inverted expression solves for "value":
# value = (result - 5) / 2
Custom Functions
Implement the Elex.Function behaviour and register your module with Elex.Context.add_function/2:
defmodule MyApp.Functions.Double do
@behaviour Elex.Function
@impl true
def signature, do: %{name: :double, arity: 1}
@impl true
def validate([arg], ctx), do: Elex.Validator.validate(arg, ctx)
@impl true
def call([arg]), do: {:ok, Decimal.mult(arg, Decimal.new(2))}
@impl true
def documentation, do: %{signature: "double(x)", description: "doubles a number"}
end
context =
Elex.new_context()
|> Elex.Context.add_function(MyApp.Functions.Double)
{:ok, result} = Elex.evaluate("double(5)", context)
Development
Setup
mix deps.get
mix test
Git Hooks
To enable the pre-commit hook that runs quality checks before each commit:
git config core.hooksPath .git-hooks
The pre-commit hook runs:
- Code compilation with warnings as errors
- Code formatting
- Credo linting
- Sobelow security checks
- Dependency audit
- All tests
You can also run these checks manually:
mix precommit
Code Quality
mix check # Run all quality checks
mix format # Format code
mix credo --strict # Run Credo linter
mix dialyzer # Run Dialyzer type checker
mix sobelow # Run security analysis
License
Copyright (c) 2025 bandesz
See LICENSE for details.