Elex

CI

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

Installation

Add elex to your list of dependencies in mix.exs:

def deps do
[
{:elex, "~> 0.4.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", context)

Expression Syntax

Literals

# Numbers (decimal, including scientific notation)
Elex.evaluate("42", Elex.new_context())
Elex.evaluate("3.14", Elex.new_context())
Elex.evaluate("-5.5", Elex.new_context())
Elex.evaluate("1e3", Elex.new_context())
Elex.evaluate("1.5E-2", 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("100 * 50%", context) # => #Decimal<50>
{: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/2 returns {:ok, result} on success or {:error, reason} on failure. Number arithmetic returns a Decimal, a quantity keeps its unit, and percent +, -, and percent-times-percent return %Elex.Percent{}. * and / have higher precedence than + and -. A % suffix on a number literal is percent points (50% stores 50), so 100 * 50% evaluates to 50.

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, percent, boolean, string, null, or same-dimension quantities when a catalog is attached). Percent points order against another percent; a literal 0 is allowed and other numbers are rejected.

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
mod(a, b) Floored modulo (sign follows the divisor)
inc(value, rate), dec(value, rate) Increase or decrease value by a percent rate
max(a, b, …), min(a, b, …) Largest or smallest of two or more numbers, percents, or same-category quantities (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)
convert(value, unit) Convert a quantity into a named unit or formula (needs a catalog)
add_unit(value, unit) Wrap a number as a quantity of a registered symbol (needs a catalog)
remove_unit(value) Magnitude of a quantity as a number (needs a catalog)
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("pi()", context) # => #Decimal<3.14159…>
{:ok, result} = Elex.evaluate("if(10 > 5, 1, 0)", context) # => #Decimal<1>

See Units for convert, add_unit, and remove_unit.

Strings

Function Description
concat(...) Concatenate zero or more strings (variadic)
length(s) String length (character count)
contains(haystack, needle) Substring search
match(text, pattern) Regex match
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[match("hello123", "hello[0-9]+")], 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>

Units

Elex does not ship a catalog. Register categories and attach one with Elex.Context.put_units/2 (or put_units!/2 when piping):

alias Elex.Units.Catalog
{:ok, catalog} = Catalog.add_category(Catalog.new(), :length, default: "m")
{:ok, catalog} = Catalog.add_unit(catalog, :length, "m")
{:ok, catalog} = Catalog.add_unit(catalog, :length, "mm", "value / 1000")
{:ok, context} = Elex.Context.put_units(Elex.new_context(), catalog)
{:ok, qty} = Elex.evaluate("10mm", context)
# qty => #Elex.Quantity<10 mm>
{:ok, %Elex.Dimension{monomial: %{length: 1}}} = Elex.validate("10mm", context)
{:ok, qty} = Elex.evaluate("10mm", context, unit: "m")
# qty => #Elex.Quantity<0.01 m>
{:ok, qty} = Elex.evaluate(~s[convert(10mm, "m")], context)
# qty => #Elex.Quantity<0.01 m>

Suffixes attach to numeric literals: a registered name (10mm), a power (5 m^2), an unbraced pipe (3 m|s), or a braced formula (1 {kg * m | s}). Variables take {n, "unit"} or %Elex.Quantity{} with category::

{:ok, context} = Elex.add_variable(context, "width", {10, "mm"}, category: :length)

Formula strings use | for division ("km | h"), not /. Without a catalog, glued suffixes are unexpected tokens (2mm → unexpected 'mm').

See the Units guide for derived categories, aliases, identity:, non-additive temperature, and custom-function units:.

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, :string, or :percent. When the context has a units catalog, it may also be a category atom such as :length. 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([%Elex.Quantity{value: value, unit: unit}]) do
{:ok, doubled} = call([value])
{:ok, %Elex.Quantity{value: doubled, unit: unit}}
end
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:

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.