Rex Documentation
Rex (short for Rush Expressions) is a strongly-typed, pure functional language built to be an excellent target for LLM-generated programs, with a focus on data processing. At a high level, you write transformations over lists, records, ADTs, and other values using familiar functional building blocks like map, filter, folds, pattern matching, and composition. The language is designed to make dataflow clear and predictable, with types and pure expressions doing most of the heavy lifting.
Rex is designed first and foremost to be embedded inside Rust applications. In that model, your Rust program acts as the host runtime and injects native functions into Rex so scripts can orchestrate real work while staying in a concise, declarative style. This makes Rex a practical scripting layer for workflow-style systems where you want strong typing and explicit control at the host boundary.
Because Rex programs are pure and free of side effects in the language itself, the runtime can safely execute host-provided async functions in parallel when it is valid to do so. In practice, that means users can write straightforward functional code and still benefit from concurrency without directly managing threads, locks, or low-level async orchestration.
Rex examples marked as interactive can be edited and run directly in the documentation. Reference snippets and Rust embedding examples are displayed as static code. A good place to start is the interactive sample below.
If you are using Rex as a code-generation target, read LLMs early. It covers the LLM-first semantic workflow, syntax pitfalls, and validation steps that reduce iteration time.
Try editing and running this intro data-processing demo:
let
values = [3, 12, 7, 20, 15, 4],
selected = filter (\n -> n >= 10) values,
adjusted = map (\n -> n - 2) selected,
total = foldl (\acc n -> acc + n) 0 adjusted
in
(values, selected, adjusted, total)
This documentation is organized into several sections:
- Tutorial — A guided walk-through of writing Rex code
- Demos — Interactive algorithm demos
- Language Reference — Compact reference for Rex syntax and features
- Specification — Locked semantics and edge cases
- Architecture — System architecture and design
- Memory Management — Memory management implementation
- Embedding — Embedding Rex in other applications
- Contributing — How to contribute to Rex
- LLMs — LLM-first semantic workflow and generation guidance
Rex as a target for LLMs
Rex is the world’s first parallel functional language explicitly designed to be a useful target for LLMs. Its strong static type system gives rapid, high-signal feedback on generated programs, so both users and models can quickly identify mismatches and converge on correct code.
That typechecking loop works especially well with Rex’s functional, expression-oriented style. Because programs are written as pure data transformations, LLM-generated code tends to be easier to inspect, reason about, and refine than imperative scripts with hidden state or side effects.
Together, these properties make Rex a strong fit for LLM-generated data analysis pipelines and scientific workflows. Models can generate high-level orchestration in Rex, while host-provided Rust functions handle domain-specific execution, giving a clean split between deterministic workflow logic and host capabilities.
Rex Tutorial
This tutorial is a guided walk-through of writing Rex code.
Examples marked as interactive can be edited and run in the docs. Some explanatory snippets are static, including deliberately incomplete or invalid code used to illustrate type errors.
If you want a compact reference instead, see the Language Reference. For locked semantics and edge cases, see the Specification.
The tutorial is divided into three sections:
- Section 1 — Basics — Core language features and syntax
- Section 2 — Advanced Topics — Type system, polymorphism, and typeclasses
- Section 3 — Worked Examples — Practical examples and patterns
Section 1 — Basics
This section covers the fundamental concepts and syntax of the Rex language.
Chapters
- Getting Started
- Expressions
- Let Bindings
- Functions
- Operators
- Collections
- Dictionaries
- Algebraic Data Types
- Pattern Matching
- Records
- Types and Annotations
- Debugging and CLI
- Prelude Tour
Getting Started
Small Rex programs can be written as one expression, optionally preceded by top-level declarations:
type— algebraic data types (ADTs)class/instance— type classes and instancesfn— top-level functions
Note: This tutorial focuses on writing Rex code. If you want to embed Rex in Rust, see Embedding.
Running Rex
A runnable Rex file either defines main or ends with a final expression. The
examples in this tutorial usually use final expressions because they keep small
programs compact.
From this repository, you can run a Rex file:
cargo run -p rex-cli --bin rex_cli -- rex-cli/examples/record_update.rex
Or evaluate a small snippet inline:
cargo run -p rex-cli --bin rex_cli -- -c 'map ((*) 2) [1, 2, 3]'
What you should see
The CLI prints the evaluated value of the program entry point in JSON format. If something fails, you’ll get a parse/type/eval error (often with a span).
What “one expression” means
Even with declarations, a program without main uses the final expression as
its result:
fn inc x: i32 -> i32 = x + 1;
let xs = [1, 2, 3] in
map inc xs
The program above:
- Declares a top-level function
inc. - Creates a list
xs. - Evaluates
map inc xsas the program’s result.
Comments
Comments use // ... for line comments and /* ... */ for block comments:
/* This is a comment */
1 + 2
Whitespace
Most whitespace is insignificant, and indentation has no syntactic meaning. Multi-line expressions are often easier to read:
let
x = 1,
y = 2
in
x + y
Commas between let bindings are required. Top-level function declarations end with semicolons,
so multi-line bodies do not depend on indentation. The parser also accepts many one-line forms, but
multi-line formatting tends to be easier to debug.
Type-class and instance method blocks use explicit braces and semicolon-separated methods:
class Size a where {
size : a -> i32;
}
An empty marker class or instance uses a semicolon:
class Marker a;
instance Marker i32;
Your first “real” Rex file
Create a file hello.rex:
let
greet = \name -> "hello, " + name
in
greet "world"
Run it:
cargo run -p rex-cli --bin rex_cli -- hello.rex
Lambda and Arrow Spelling
Rex uses ASCII-only syntax for lambdas and arrows:
\and->
The Unicode lambda and right-arrow glyphs are not accepted.
Expressions: Values and Control Flow
Rex is expression-oriented: everything produces a value.
This page introduces the “everyday” expression forms you’ll use constantly.
Literals
( true
, false
, 123
, 3.14
, 'λ'
, "hello"
)
Common primitive types are Bool, i32, f32, Char, and String (plus UUID, Hash,
DateTime if enabled by the host). A character literal uses single quotes and contains exactly one
Unicode scalar value; strings use double quotes.
Integers vs floats
123 is an integer literal. It can specialize to any Integral type from context, and defaults to
i32 when ambiguous.
3.14 is a float literal and defaults to f32.
If you need to force a different numeric type, you can use an annotation (covered later).
( (4 is u8)
, (4 is i64)
, (-3 is i32)
)
When the target type is already known, Rex can widen primitive integers without losing information:
an i8 value can flow into an i32 parameter. Mixed-width arithmetic still needs a single
operator type; Rex does not guess a common type for (1 is i8) + (2 is i32).
Negative numbers
Rex supports negative integer literals:
-420
Negative literals require a signed numeric type. For example, (-3 is u8) is a type error, while
(-3 is i16) is valid.
When you’re unsure about parsing, you can always write subtraction explicitly:
0 - 1
If / then / else
if is an expression and must have an else:
let x = 10 in
if x < 0 then "neg" else "non-neg"
A common mistake
if requires both branches and they must have the same type:
// Not OK: the branches disagree ("String" vs "i32")
if true then "yes" else 0
Equality and comparisons
Comparisons are ordinary functions (usually from the prelude type classes):
( 1 == 2
, 1 != 2
, 1 < 2
, 2 >= 2
)
If you try to compare a type without an Eq / Ord instance, typechecking will fail.
Working with strings
String concatenation uses + (via AdditiveMonoid String):
"Rex " + "rocks"
Because + is type-class-based, the same syntax also works for numeric addition.
Grouping: parentheses are your friend
When in doubt, add parentheses—especially when mixing application and infix operators:
let f = \x -> x + 1 in
f (1 + 2)
Let Bindings and Scope
let ... in ... introduces local bindings.
Think of let as: “name some sub-expressions so you can reuse them and make types clearer”.
One binding
let x = 1 + 2 in x * 10
Multiple bindings
Bindings can be written on separate lines (typically separated by commas):
let
x = 1 + 2,
y = x * 3
in
(x, y)
Local helper functions
Because functions are values, let is the normal way to define local helpers:
let
inc = \x -> x + 1,
double = \x -> x * 2
in
double (inc 10)
Scope
Bindings are visible only in the in body (and later bindings):
let
x = 10,
y = x + 1
in
y
Recursive bindings
Rex supports writing recursive helpers via let rec. This is the easiest way to write loops:
let rec
sum = \xs ->
match xs with {
case [] -> 0;
case x::xs -> x + sum xs;
}
in
sum [1, 2, 3, 4]
Mutually-recursive helpers use comma-separated bindings:
let rec
even = \n -> if n == 0 then true else odd (n - 1),
odd = \n -> if n == 0 then false else even (n - 1)
in
even 10
Tip: If you’re coming from languages with
forloops, think “write a recursive function + match on a list” in Rex.
Let-polymorphism (preview)
Let bindings are generalized (HM let-polymorphism), so one binding can be used at multiple types:
let id = \x -> x in (id 1, id true, id "hi")
This is one of the core reasons to use let: it lets you build small reusable utilities without
constantly writing type annotations.
Functions and Lambdas
Functions are values. The most common way to write one is a lambda.
Lambdas
\x -> x + 1
Lambdas can take multiple arguments:
\x y -> x + y
Rex only accepts the ASCII spellings \ and ->.
Annotating lambda parameters
You can annotate parameters when you need to force a specific type:
\(x: i32) -> x + 1
Application
Function application is left-associative:
f x y
is parsed as:
(f x) y
This is why parentheses are important when an argument is itself an application.
Functions returning functions (currying)
let add = \x -> (\y -> x + y) in
(add 1) 2
Partial application
Because functions are curried, you can supply fewer arguments to get a new function back:
let add1 = (+) 1 in add1 41
Top-level functions (fn)
Top-level functions require parameter types, a return type, and a semicolon terminator. The
recommended form puts each parameter name next to its type in the fn header:
fn add x: i32 -> y: i32 -> i32 = x + y;
This declares a function that takes an i32 and returns another function i32 -> i32. The
semicolon terminates the top-level declaration, so multi-line bodies do not depend on indentation.
Top-level fn declarations are mutually recursive, so they can reference each other:
fn even n: i32 -> Bool =
if n == 0 then true else odd (n - 1);
fn odd n: i32 -> Bool =
if n == 0 then false else even (n - 1);
even 10
Alternative fn forms
Rex also accepts a parenthesized parameter in the header:
fn inc (x: i32) -> i32 = x + 1;
You can also write the full function type after the function name and provide a lambda body:
fn add : i32 -> i32 -> i32 = \x y -> x + y;
These are equivalent alternatives. The named-parameter header form is recommended because it keeps parameter names and types adjacent to each other.
fn constraints with where
Top-level functions can also have type-class constraints:
fn sum_list xs: List i32 -> i32 where Foldable List = foldl (+) 0 xs;
If you haven’t seen where constraints before, Section 2 covers them in detail.
Operators and Precedence
Operators like + and == are just functions with infix syntax.
Using an operator as a value
Parentheses turn an operator into a function value:
(+) 1 2
This enables partial application:
map ((*) 2) [1, 2, 3]
Operators come from type classes
Many operators are methods on prelude classes:
+/zerofromAdditiveMonoid*/onefromMultiplicativeMonoid==/!=fromEq- ordering from
Ord
This is why you can write + for both numbers and strings.
Precedence
Rex has a fixed precedence table (see Language Reference). A good habit is to use parentheses whenever you mix application with multiple infix operators.
(1 + 2) * 3
Record projection is not an operator
x.field is field projection syntax, not an operator you can partially apply:
type User = User { name: String };
let u: User = User { name = "Ada" } in u.name
Tuples, Lists, and Dictionaries
Rex supports several lightweight data shapes.
Tuples
Tuples group fixed-position values:
(1, "hi", true)
Rex supports tuple patterns in match and let. For indexing, use numeric projection
like .0 and .1.
Indexing tuples with .
let t = (1, "hi", true) in t.1
Lists
List literals use square brackets:
[1, 2, 3]
Under the hood, lists are a prelude ADT List a with constructors Empty and Cons.
You can construct cons cells either as Cons h t (normal constructor call style) or with h::t sugar.
let xs = 1::2::3::[] in xs
match [1, 2, 3] with {
case Empty -> 0;
case Cons h t -> h;
}
List patterns (sugar)
Rex also supports list-pattern sugar:
match [1, 2, 3] with {
case [] -> 0;
case [x] -> x;
case x::xs -> x;
}
Lists At Host Boundaries
Rex exposes one ordered collection type: List a. User-written list literals,
list constructors, pattern matching, and Rust host Vec<T> values all use this
same type.
Internally, the runtime may store a list as linked Cons / Empty cells or as
a slice over contiguous heap data. Rex code does not need to choose or convert
between those representations.
let
data = [1, 2, 3]
in
match data with {
case x::xs -> x;
case [] -> -1;
}
For embedders, a Rust function returning Vec<i32> is exposed in Rex as
returning List i32, and a Rust parameter of type Vec<i32> accepts any Rex
List i32.
Dictionaries (records / dict values)
Dictionary literals use braces:
{ a = 1, b = 2 }
These are “record-like” values. Depending on context they may be treated as a record type
({ a: i32, b: i32 }) or as a dictionary-like value; either way, you can project fields when the
field is known to exist:
type R = R { a: i32, b: i32 };
let r: R = R { a = 1, b = 2 } in r.a
Forcing a dictionary type
If you want a polymorphic “dictionary” (instead of a specific record type), use type ascription
with is:
({ a = 1, b = 2 }) is Dict i32
Dict a has String keys and values of one uniform type a. Dictionary literals use identifier
keys, while functions such as dict_insert and dict_from_entries also accept arbitrary runtime
strings.
For a complete, function-by-function dictionary reference with signatures and runnable examples, see Dictionaries.
Dictionary operations
Lookup is option-based, and updates return new dictionaries:
let
d0 = dict_singleton "alpha" 1,
d1 = dict_insert "beta" 2 d0,
d2 = dict_update "alpha" (\old -> map ((+) 10) old) d1,
d3 = dict_remove "beta" d2
in
(dict_get "alpha" d3, dict_has "beta" d3)
dict_keys, dict_values, and dict_entries return lists in lexicographic key order.
dict_from_entries performs the inverse conversion; if a key occurs more than once, its last
entry wins.
The ordinary map, filter, and filter_map functions operate on dictionary values while
preserving their keys. When the key is also needed, use dict_map or dict_filter; their callbacks
receive a (String, a) tuple:
let
d = (({ a = 1, b = 2 }) is Dict i32),
renamed = dict_map
(\entry -> match entry with {
case (key, value) -> ("prefix_" + key, value * 10);
})
d,
selected = dict_filter
(\entry -> match entry with {
case (key, value) -> key != "b" && value > 0;
})
d
in
(renamed, selected)
dict_map may produce the same output key from multiple input entries. Results are applied in the
input dictionary’s lexicographic key order, so the result produced for the latest input key wins.
Matching dictionaries
Dictionary patterns check for key presence and bind those keys to variables:
let d = ({ a = 1, b = 2 }) is Dict i32 in
match d with {
case {a, b} -> a + b;
case {a} -> a;
case {} -> 0;
}
{} is useful as a fallback: it requires no keys, so it matches any dict.
Dictionaries
A dictionary in Rex is an immutable mapping from String keys to values. Its type is Dict a, where
a is the type of every value in the dictionary:
- Keys are always
String. - All values in one dictionary have the same type.
- Operations return new dictionaries; they do not change their inputs.
For example, a Dict i32 can contain any number of integer values, but it cannot mix integers,
strings, or other value types:
let scores = ({ alice = 10, bob = 12 }) is Dict i32 in scores
Dictionary literals use identifier-shaped keys. Functions such as dict_singleton, dict_insert,
and dict_from_entries accept arbitrary String values as keys, including strings containing spaces.
Dictionary order
Rex stores dictionary entries in ascending lexicographic key order. This determines the order
returned by dict_keys, dict_values, and dict_entries. It also makes collision handling in
dict_map deterministic.
Function argument order
Rex functions are curried. Dictionary operations conventionally take the dictionary as their last argument, which makes partial application straightforward. For example:
let get_alice = dict_get "alice" in
get_alice (({ alice = 10, bob = 12 }) is Dict i32)
In the signatures below, a and b are type variables. Every occurrence of the same variable must
be the same type.
Every example below is a complete executable Rex expression. You can edit it in place and run it to see the resulting value.
Quick reference
| Function | Type |
|---|---|
dict_empty | Dict a |
dict_singleton | String -> a -> Dict a |
dict_get | String -> Dict a -> Option a |
dict_has | String -> Dict a -> Bool |
dict_insert | String -> a -> Dict a -> Dict a |
dict_remove | String -> Dict a -> Dict a |
dict_update | String -> (Option a -> Option a) -> Dict a -> Dict a |
dict_is_empty | Dict a -> Bool |
dict_keys | Dict a -> List String |
dict_values | Dict a -> List a |
dict_entries | Dict a -> List (String, a) |
dict_from_entries | List (String, a) -> Dict a |
dict_map | ((String, a) -> (String, b)) -> Dict a -> Dict b |
dict_filter | ((String, a) -> Bool) -> Dict a -> Dict a |
map on Dict | (a -> b) -> Dict a -> Dict b |
filter on Dict | (a -> Bool) -> Dict a -> Dict a |
Construction
dict_empty
Type: dict_empty : Dict a
dict_empty is an empty dictionary. Because it contains no values, its value type normally comes
from the surrounding context or an explicit annotation.
let numbers: Dict i32 = dict_empty in numbers
dict_singleton
Type: dict_singleton : String -> a -> Dict a
dict_singleton key value constructs a dictionary containing exactly one entry.
dict_singleton "request id" "req-123"
dict_from_entries
Type: dict_from_entries : List (String, a) -> Dict a
dict_from_entries entries constructs a dictionary from a list of key/value tuples. It processes
the list from first to last. If a key appears more than once, its last value wins. The resulting
dictionary is stored in lexicographic key order, not list order.
dict_from_entries
[("beta", 2), ("alpha", 1), ("beta", 20)]
Lookup and inspection
dict_get
Type: dict_get : String -> Dict a -> Option a
dict_get key dictionary returns Some value when the key exists and None when it does not.
Lookup does not fail merely because a key is absent.
let scores = dict_from_entries [("alice", 10), ("bob", 12)] in
(dict_get "alice" scores, dict_get "carol" scores)
dict_has
Type: dict_has : String -> Dict a -> Bool
dict_has key dictionary reports whether the key exists, without retrieving its value.
let scores = dict_from_entries [("alice", 10), ("bob", 12)] in
(dict_has "bob" scores, dict_has "carol" scores)
dict_is_empty
Type: dict_is_empty : Dict a -> Bool
dict_is_empty dictionary is true only when the dictionary has no entries.
let empty: Dict String = dict_empty in
(dict_is_empty empty, dict_is_empty (dict_singleton "name" "Rex"))
dict_keys
Type: dict_keys : Dict a -> List String
dict_keys dictionary returns all keys in ascending lexicographic order.
dict_keys (dict_from_entries [("z", 1), ("alpha", 2), ("middle", 3)])
dict_values
Type: dict_values : Dict a -> List a
dict_values dictionary returns the values ordered by their corresponding keys. It does not sort
the values themselves.
dict_values (dict_from_entries [("z", 1), ("alpha", 2), ("middle", 3)])
The result is [2, 3, 1], corresponding to keys alpha, middle, and z.
dict_entries
Type: dict_entries : Dict a -> List (String, a)
dict_entries dictionary returns key/value tuples in ascending lexicographic key order.
dict_entries (dict_from_entries [("z", 1), ("alpha", 2), ("middle", 3)])
Immutable updates
dict_insert
Type: dict_insert : String -> a -> Dict a -> Dict a
dict_insert key value dictionary returns a new dictionary. It adds an absent key or replaces the
value of a present key. The input dictionary remains unchanged.
let
original = dict_singleton "a" 1,
added = dict_insert "b" 2 original,
replaced = dict_insert "a" 99 original
in
(original, added, replaced)
dict_remove
Type: dict_remove : String -> Dict a -> Dict a
dict_remove key dictionary returns a new dictionary without that key. Removing an absent key
returns an equivalent dictionary and is not an error.
let original = dict_from_entries [("a", 1), ("b", 2)] in
(original, dict_remove "b" original, dict_remove "missing" original)
dict_update
Type: dict_update : String -> (Option a -> Option a) -> Dict a -> Dict a
dict_update key update dictionary calls update with Some current_value when the key is present
or None when it is absent. The callback’s result controls the new dictionary:
Some new_valueinserts or replaces the key.Noneremoves the key.
let
original = dict_singleton "visits" 2,
incremented = dict_update
"visits"
(\current -> match current with {
case Some count -> Some (count + 1);
case None -> Some 1;
})
original,
removed = dict_update "visits" (\_ -> None) incremented
in
(original, incremented, removed)
Transforming entries
The dict_map and dict_filter callbacks receive a two-element (String, a) tuple, so they can
inspect both the key and value. Use a tuple pattern in a match expression to name both parts.
Callback applications for different entries may run in parallel. Rex functions are pure, so this does not change the result.
dict_map
Type: dict_map : ((String, a) -> (String, b)) -> Dict a -> Dict b
dict_map transform dictionary transforms both keys and values. The callback must return a
(String, b) tuple, which becomes an entry in the result.
let
input = (({ c = 3, a = 1, b = 2 }) is Dict i32),
renamed = dict_map
(\entry -> match entry with {
case (key, value) -> ("item_" + key, value * 10);
})
input
in
renamed
Multiple callbacks may return the same output key. Rex applies completed results in the input dictionary’s original lexicographic key order, regardless of callback completion order. Therefore, the result from the latest input key wins:
dict_map
(\entry -> match entry with {
case (key, value) -> ("same", key + ":" + show value);
})
(({ c = 3, a = 1, b = 2 }) is Dict i32)
The input order is a, b, c, so the final dictionary contains "c:3" at key same.
dict_filter
Type: dict_filter : ((String, a) -> Bool) -> Dict a -> Dict a
dict_filter predicate dictionary keeps each original entry for which the predicate returns
true. The predicate can inspect both key and value. Accepted entries keep their original keys and
values.
dict_filter
(\entry -> match entry with {
case (key, value) -> key != "draft" && value >= 2;
})
(({ draft = 10, first = 1, second = 2, third = 3 }) is Dict i32)
Transforming values with typeclass functions
Dict implements Functor and Filterable. Their generic functions operate on values only; the
callback does not receive a key. These functions preserve every retained entry’s original key.
Like the entry-aware functions, callback applications for different values may run in parallel.
map on dictionaries
Type: map : (a -> b) -> Dict a -> Dict b
map transform dictionary transforms every value and preserves every key. Use dict_map instead
when the callback needs the key or must produce new keys.
map
(\score -> "score=" + show score)
(({ alice = 10, bob = 12 }) is Dict i32)
filter on dictionaries
Type: filter : (a -> Bool) -> Dict a -> Dict a
filter predicate dictionary tests values only and preserves the keys of accepted values. Use
dict_filter when filtering depends on a key.
filter
(\score -> score >= 10)
(({ alice = 10, bob = 7, carol = 12 }) is Dict i32)
Choosing the right transformation
| Need | Function |
|---|---|
| Transform values and preserve all keys | map |
| Keep entries based only on values | filter |
| Transform keys and/or inspect keys while mapping | dict_map |
| Keep entries based on keys and/or values | dict_filter |
For dictionary literal syntax and dictionary pattern matching, see Collections.
Algebraic Data Types (ADTs)
ADTs let you define your own sum types. Top-level type declarations end with semicolons.
You’ll use ADTs to model “this or that” choices: optional values, tagged unions, trees, results, etc.
Simple ADT
type Maybe a = Just a | Nothing;
Constructors are values:
type Maybe a = Just a | Nothing;
let v = Just 1 in
(v, Nothing)
Using ADTs is all about match
Defining an ADT is only half the story; consuming it is done with pattern matching:
type Maybe a = Just a | Nothing;
let
fromMaybe = \d m ->
match m with {
case Just x -> x;
case Nothing -> d;
}
in
fromMaybe 0 (Just 5)
Constructors with multiple fields
type Pair a b = Pair a b;
let v = Pair 1 "hi" in
v
This is a single-constructor ADT (a “product type”). In many programs you’ll use record-carrying constructors instead because they self-document field names.
Record-carrying constructors
Variants can carry a record payload:
type User = User { name: String, age: i32 };
let u = User { name = "Ada", age = 36 } in
u
This style works well with field projection and update (covered later).
Multi-variant and recursive ADTs
You can define sum types with multiple constructors, including recursive ones:
type Tree
= Leaf { value: i32 }
| Node { left: Tree, right: Tree };
Recursive ADTs are the foundation for ASTs, expression trees, and many structured data problems.
Pattern Matching with match
Use match to branch on the shape of values.
match is the “workhorse” control flow construct in Rex. You’ll use it for:
- consuming ADTs (
Option,Result, your own types), - splitting lists (
[]vsx::xs), - checking key presence in dicts (
{a, b}), - refining record-carrying variants so projection/update typecheck.
Matching ADTs
type Maybe a = Just a | Nothing;
let fromMaybe = \d m ->
match m with {
case Just x -> x;
case Nothing -> d;
}
in
fromMaybe 0 (Just 5)
Rex checks matches for exhaustiveness on ADTs and reports missing constructors.
Inline match syntax
You’ll often see compact “inline” matches in examples:
match (Some 1) with { case Some x -> x; case None -> 0; }
Common patterns
Wildcards:
match [1, 2, 3] with {
case Empty -> 0;
case Cons _ _ -> 1;
}
List patterns:
match [1, 2] with {
case [] -> 0;
case [x] -> x;
case [x, y] -> x + y;
case _ -> 0;
}
Cons patterns:
match [1, 2, 3] with {
case h::t -> h;
case [] -> 0;
}
h::t is equivalent to Cons h t; both expression forms are valid.
Record patterns on record-carrying constructors:
type Point = Point { x: i32, y: i32 };
let p = Point { x = 1, y = 2 } in
match p with {
case Point { x: x, y: y } -> x + y;
}
Dict key presence patterns:
let d = ({ a = 1, b = 2 }) is Dict i32 in
match d with {
case {a, b} -> a + b;
case {a} -> a;
case {} -> 0;
}
Arrow spelling
Match arms use ->:
type Bit = T | F;
let v = T in
match v with {
case T -> 1;
case F -> 0;
}
Ordering and fallbacks
Match arms are tried top-to-bottom. Put specific patterns first and broad patterns (like _ or
{}) last.
Records: Projection and Update
Records are key/value structures with named fields. Rex supports:
- field projection:
base.field - record update:
{ base with { field = expr } }
At the value level, “record” literals are written like dicts:
{ x = 1, y = 2 }
At the type level, record types are written with ::
let p: { x: i32, y: i32 } = { x = 1, y = 2 } in
p
Give a record shape a reusable name with a transparent alias. The alias does not introduce a constructor; its values are still ordinary records:
type Point = { x: i32, y: i32 };
let p: Point = { x = 1, y = 2 } in p
Projection
type Point = { x: i32, y: i32 };
let p: Point = { x = 1, y = 2 } in p.x
Projection is accepted when the field is definitely available on the type (see Specification).
Tip: If you get a “field not definitely available” type error, it usually means the typechecker can’t prove which ADT variant you have. A
matchoften fixes it.
Update
type Point = { x: i32, y: i32 };
let p: Point = { x = 1, y = 2 } in
{ p with { x = p.x + 10 } }
Updates can set multiple fields at once:
type Point = { x: i32, y: i32 };
let p: Point = { x = 1, y = 2 } in
{ p with { x = 100, y = 200 } }
Updating record-carrying ADT variants
This is a common pattern:
type Sum = A { x: i32 } | B { x: i32 };
let s: Sum = A { x = 1 } in
match s with {
case A {x} -> { s with { x = x + 1 } };
case B {x} -> { s with { x = x + 2 } };
}
The match arms refine which constructor s has, allowing the update to typecheck.
Types and Annotations
Rex uses Hindley–Milner type inference, but you can (and often should) add annotations.
This page is about the “tools” you use to make types explicit when inference isn’t enough.
Type names
Examples of primitive and constructed types:
Bool,i32,f32,Char,String(a, b)for tuplesList a,Option a,Promise a,Result a e(prelude/built-in constructors)
Lowercase names in type positions are type parameters only after a surrounding form declares them:
fn id<a> x: a -> a = x;
id 1
The same rule applies to let, declare fn, class methods, and instances:
let id<a>: a -> a = \x -> x in id "hello"
Function types are right-associative:
i32 -> i32 -> i32
means:
i32 -> (i32 -> i32)
Record types
Record types use ::
{ x: i32, y: i32 }
Record values use =:
{ x = 1, y = 2 }
Let annotations
let x: i32 = 1 in x
Lambda parameter annotations
\(x: i32) -> x + 1
Annotating expressions
You can also annotate via a let-binding when you want to force a particular type:
let xs: List i32 = [1, 2, 3] in xs
Type ascription with is
Rex also supports an expression-level “ascription” form:
({ a = 1, b = 2 }) is Dict i32
You’ll see is used in examples for two common reasons:
- To force a dictionary type (
Dict a) instead of a specific record type. - To disambiguate overloaded values (similar to adding a let-annotation).
Warning: Use
iswhen it helps clarity, but don’t overuse it: most of the time, simpleletannotations are easier to read.
Debugging: CLI Tips and Common Errors
Rex is prepared and evaluated in stages:
- Parsing
- Import/declaration preparation
- Type inference / checking
- Evaluation
Most debugging is about figuring out which stage is failing and adding just enough information to make the problem obvious.
Useful CLI flags
Run a Rex file:
cargo run -p rex-cli --bin rex_cli -- path/to/file.rex
Run a file with JSON inputs for its entry point:
cargo run -p rex-cli --bin rex_cli -- path/to/program.rex --inputs path/to/inputs.json
The inputs file is a top-level JSON object. Each field name must match a
parameter of main; runnable files without main use their final expression
and have the empty input shape {}.
Inspect the entry point type metadata:
cargo run -p rex-cli --bin rex_cli -- path/to/program.rex --manifest
Run an inline snippet:
cargo run -p rex-cli --bin rex_cli -- -c 'let x = 1 in x + 2'
Show the parsed AST and exit:
cargo run -p rex-cli --bin rex_cli -- --emit-ast -c '1 + 2'
Show the entry point result type and exit:
cargo run -p rex-cli --bin rex_cli -- --emit-type -c 'map ((*) 2) [1, 2, 3]'
Print a string result without JSON quotes:
cargo run -p rex-cli --bin rex_cli -- --raw-output -c '"hello"'
“Parse error”: start small
If you hit a parse error:
- Reduce the program to the smallest failing snippet.
- Add parentheses to disambiguate application vs infix operators.
- Prefer multi-line
let/matchwhile debugging.
“Missing typeclass impl”
This usually means you called a type-class method at a type that has no instance.
Typical fixes:
- use a different type (
ListvsOption,OptionvsResult, …), - add an instance (Section 2),
- add a type annotation so the intended instance is selected.
“Ambiguous overload”
This happens when an overloaded value doesn’t have enough information to pick an instance.
Typical fixes:
- add a let-annotation:
let z: i32 = zero in z - add
isascription:(zero) is i32(if you prefer expression ascription style) - use the value in a context that forces a type (e.g.
zero + 1).
The exact defaulting rules are described in Specification.
A Tour of the Prelude
Rex ships with a small “prelude” of standard types, type classes, and instances.
The source of truth in this repository is:
- type classes + instances:
rex-engine/src/prelude/typeclasses.rex - runtime builtins + helper wiring:
rex-engine/src/prelude/mod.rs - standard type-system construction:
rex-engine/src/prelude/type_system.rs
This page is a guided map so you know what to reach for while writing Rex.
Core data types
These are available by default:
List a(with constructorsEmptyandCons)Option a(constructorsSomeandNone)Result a e(constructorsErrandOk)Dict a
Core classes (selected)
Numeric-like classes
AdditiveMonoid a(zero,+)MultiplicativeMonoid a(one,*)Ring a,Field a,Integral a(and friends)
Equality and ordering
Eq a(==,!=)Ord a(cmp,<,<=,>,>=)Default a(default)
Default gives you a value-level default for a type. It is separate from Rex’s
defaulting pass, which resolves ambiguous type variables for defaultable classes.
Collections and effects
Functor f(map)Applicative f(pure,ap)Monad m(bind)Foldable t(foldl,foldr,fold)Filterable f(filter,filter_map)Sequence f(take,skip,zip,unzip)
A few useful helper functions
The prelude also exposes some generic helpers (type-class-based):
sum,mean,length,min,maxfirst,last,slicefor strict list range extractionlist_get,list_slice,list_find, and other total list helpersis_some,is_none(forOption)is_ok,is_err(forResult)
How to learn what something is
When you see an unfamiliar function:
- Ask the CLI for its type:
cargo run -p rex-cli --bin rex_cli -- --emit-type -c 'the_name' - If it’s a type-class method, find the class in
rex-engine/src/prelude/typeclasses.rex - If it’s a Rust-backed helper or primitive, find the runtime wiring in
rex-engine/src/prelude/mod.rs
This workflow is especially helpful when you’re building your own abstractions.
Section 2 — Advanced Topics
This section covers advanced type system features, polymorphism, and functional programming patterns.
Chapters
- Type Inference
- Polymorphism
- Typeclasses
- Instances
- Constraints and Where
- Resolution and Coherence
- Functor
- Applicative
- Monad
- Writing Instances
- Defaulting
- Higher-Kinded Types
Type Inference (Hindley–Milner)
Rex infers types for most expressions. You get type errors when constraints can’t be satisfied or when the program would require ambiguous instance selection.
This section is intentionally practical: it’s about recognizing when the typechecker needs help, and what kinds of help work best.
A simple inference example
\x -> x
This is polymorphic: it can be used at any type (a -> a).
Try it
Ask the CLI for the type:
cargo run -p rex-cli --bin rex_cli -- --emit-type -c '\\x -> x'
Inference plus operators
\x -> x + 1
This adds constraints (here, a numeric type class for +).
What changed?
The expression no longer works at “any type” because + only exists for types with an
AdditiveMonoid instance (numbers and strings in the prelude).
When inference fails
You’ll see errors when:
- branches of an
ifdon’t match types, - you call a type-class method with no applicable instance,
- you use an overloaded value without enough type information (ambiguity).
For details on ambiguity and defaulting, see Specification.
The most common “fixes”
- Add a type annotation to a
letbinding. - Use expression ascription with
is. - Restructure code so an argument forces a type (e.g. apply a polymorphic function).
Polymorphism and Let-Generalization
The most visible “HM feature” in Rex is that let bindings can be reused at multiple types.
If you’re new to HM languages, this is the big shift from “everything has one type” to “some definitions are generic”.
A polymorphic helper
let id = \x -> x in
(id 1, id true, id "hi")
Why lambdas aren’t generalized
Inside a lambda body, parameters are monomorphic unless you explicitly abstract:
\f ->
let x = f 1 in
f x
If f were required to work at multiple unrelated types, that would be rejected.
Practical implication
If you want something reusable, let-bind it at the outer level:
let
id = \x -> x,
use = \x -> (id x, id x)
in
use 1
Practical tip
If a definition should be reusable, prefer let-binding it and giving it a clear name (and often a
type annotation).
In practice, you’ll use:
letfor reusable helpers,- lambdas for “inline glue” (callbacks passed to
map,foldl,bind, …), fnfor API-like top-level functions with stable signatures.
Type Classes: Defining Overloads
Type classes define a set of operations that can be implemented for many types.
Rex type classes are similar to Haskell’s: they are compile-time constraints with runtime dictionary resolution.
Defining a class
class Size a where {
size : a -> i32;
}
Method signatures can mention the class parameter a and any other types in scope.
Empty Classes
Classes with methods use where { ... }. Marker classes with no methods use a semicolon:
class Marker a;
Operators as methods
class Eq a where {
== : a -> a -> Bool;
!= : a -> a -> Bool;
}
Superclasses
Superclasses use <= (read “requires”):
class Ord a <= Eq a where {
< : a -> a -> Bool;
}
If you have an Ord a, you also must have an Eq a instance.
Multi-parameter classes (tupled)
User-defined classes may take multiple type parameters. For example:
In Rex source you write:
class Convert a b where {
convert : a -> b;
}
In where constraints, multi-parameter classes are written using a tuple:
where Convert (a, b) -> ...
This matches the implementation model described in Specification.
Instances: Implementing Type Classes
Instances attach method implementations to a concrete “head” type.
An instance has three parts:
- The class name (
Show,Size,Functor, …) - The instance head type (what you’re implementing it for)
- An optional instance context (
<= ...) of required constraints
A monomorphic instance
class Show a where {
show : a -> String;
}
instance Show i32 where {
show = \_ -> "<i32>";
}
Class names in instance headers can be module-qualified when imported via alias:
import dep as D;
instance D.Show i32 where {
show = \_ -> "<i32>";
}
A polymorphic instance with context
Instance contexts use <=:
instance<a> Show (List a) <= Show a where {
show = \xs ->
let
step = \out x ->
if out == "["
then out + show x
else out + ", " + show x,
out = foldl step "[" xs
in
out + "]";
}
Read this as: “Show (List a) exists as long as Show a exists”.
Why the context matters
Inside show for lists, we call show x. That requires Show a, so we must list it in the
instance context.
Non-overlap (coherence)
Rex rejects overlapping instance heads for the same class. This keeps method lookup deterministic.
In practical terms: you can’t have two different Show (List a) instances in scope at once.
Constraints and where
Sometimes a function is only valid when certain type-class constraints hold. In Rex you’ll see
those constraints in type signatures (for fn) and in where clauses (commonly for lambdas).
Constrained lambdas
This example is from rex-cli/examples/type_classes.rex:
let
use_classes<f,a> =
\ (x: List a) (y: f a) (z: a) where Foldable f ->
let
first = unwrap (list_get 0 x),
total = foldl (\acc _ -> acc) z y
in
(first, total, z)
in
let result: (i32, i32, i32) = use_classes [10, 20, 30] [1, 2, 3] 0 in result
Notes:
where ... ->attaches constraints to the lambda.- Constraints can use module-qualified class names when imported via alias (for example
where M.ClassName t -> ...).
Constrained top-level functions
Top-level functions can also have a where clause:
fn sum_list : List i32 -> i32 where Foldable List = \xs -> foldl (+) 0 xs;
Constraints appear after the type signature and before =.
Multiple constraints
Constraints are comma-separated:
fn demo : List i32 -> i32 where Foldable List, AdditiveMonoid i32 = \xs -> foldl (+) 0 xs;
Note: In many cases you don’t need to write constraints for concrete prelude types (
List,Option, etc.) because the argument type already forces instance selection.wherebecomes more important when you want polymorphic constraints (e.g. “for anyfwithFoldable f”).
Resolution, Coherence, and Ambiguity
Type-class methods in Rex are resolved based on the inferred type at the call site.
This page answers “why did the typechecker complain?” when you’re using overloaded methods.
Coherence: why overlap is rejected
If two instances could match the same call, the runtime wouldn’t know which method to pick. Rex rejects such overlaps per class.
Deferred resolution for function values
Rex can keep an overloaded function value around and resolve it later when you apply it:
let f = map ((+) 1) in
( f [1, 2, 3]
, f (Some 41)
)
Here map is a Functor method. The engine picks the right map implementation when it sees the
argument type (List i32 vs Option i32).
Why this works
map ((+) 1) is still a function, so Rex can defer selecting the Functor instance until the
function is applied to a concrete container.
Ambiguity for non-function values
If you use an overloaded method as a non-function value and the type is not determined, resolution can be ambiguous and Rex will error.
For example, pure 1 is ambiguous by itself because it could be List i32, Option i32,
List i32, Result i32 e, etc.
Fix it by forcing a type:
let x: Option i32 = pure 1 in x
For the exact rules, see Specification (“Type Classes: Coherence, Resolution, and Ambiguity”).
Functors
The prelude defines:
class Functor f where {
map<a,b> : (a -> b) -> f a -> f b;
}
map applies a pure function inside a container f.
If you can describe an operation as “change the values without changing the structure”, it’s a
good fit for map.
Mapping over lists
map ((*) 2) [1, 2, 3]
Mental model
Each element is transformed independently; list length stays the same.
Mapping over Option
( map ((+) 1) (Some 41)
, map ((+) 1) None
)
None acts like “no value to transform”.
Mapping over Result
( map ((*) 2) (Ok 21)
, map ((*) 2) (Err "boom")
)
map transforms Ok values but leaves Err unchanged.
A useful pattern: composing transforms
Instead of branching on shapes early, keep your code “in the functor”:
let inc = \x -> x + 1 in
map inc (Some 1)
Applicatives
An Applicative is a Functor that can inject values and apply wrapped functions:
class Applicative f <= Functor f where {
pure<a> : a -> f a;
ap<a,b> : f (a -> b) -> f a -> f b;
}
Applicatives are great when you want to combine independent computations that live “in a container”.
pure
let x: Option i32 = pure 1 in x
The type depends on context. For example, this forces Option:
let x: Option i32 = pure 1 in x
A common pattern: building up computations
Because functions are curried, you can apply step-by-step. For Option:
ap (ap (pure (\x y -> x + y)) (Some 1)) (Some 2)
ap with Option
ap (Some ((+) 1)) (Some 41)
If either side is “missing”, the result is missing:
( ap None (Some 1)
, ap (Some ((+) 1)) None
)
Applicative style
You can build up multi-argument computations by applying step-by-step:
ap (ap (pure (\x y -> x + y)) (Some 1)) (Some 2)
Monads
Monads are about sequencing computations where the next step depends on the previous result.
In Rex, the core monad operation is bind:
class Monad m <= Applicative m where {
bind<a,b> : (a -> m b) -> m a -> m b;
}
Note the argument order: function first, then the monadic value.
If you come from Haskell: Rex’s bind corresponds to (>>=) but with the arguments flipped.
Option as a Monad
let
safe_inc = \x -> Some (x + 1),
step = \x -> bind safe_inc x
in
step (Some 1)
More realistically, you inline the next step:
bind (\x -> Some (x + 1)) (Some 41)
Why monads matter
With Option, monadic sequencing means “stop early if something is missing” without deeply nested
match expressions.
Result as a Monad
Result a e is useful for short-circuiting on the first Err:
let
ok = Ok 1,
boom = Err "boom"
in
( bind (\x -> Ok (x + 1)) ok
, bind (\x -> Ok (x + 1)) boom
)
“Do notation” without syntax
Rex doesn’t require special syntax. You can write sequencing explicitly with bind and lambdas:
bind (\x ->
bind (\y ->
pure (x + y)
) (Some 2)
) (Some 1)
Tip: When your
bindchains get hard to read, consider extracting the steps into namedletbindings.
For example, the same logic with named steps:
let
add_x_y = \x y -> pure (x + y),
step_y = \x -> bind (add_x_y x) (Some 2),
run = \mx -> bind step_y mx
in
run (Some 1)
Writing Your Own Instances (Including Functor/Applicative/Monad)
This page is a hands-on guide to defining your own instances for custom ADTs.
We’ll build a tiny container type and give it Functor, Applicative, and Monad instances.
Note:
Functor,Applicative, andMonadare provided by the Rex prelude. The examples below assume those classes already exist (so we only writetype/instance).
Step 1: define a container ADT
type Box a = Box a;
This is a single-variant ADT that “wraps” a value.
Step 2: make it a Functor
type Box a = Box a;
instance Functor Box where {
map = \f bx ->
match bx with {
case Box x -> Box (f x);
};
}
Now you can:
type Box a = Box a;
instance Functor Box where {
map = \f bx ->
match bx with {
case Box x -> Box (f x);
};
}
map ((+) 1) (Box 41)
Step 3: make it an Applicative
type Box a = Box a;
instance Functor Box where {
map = \f bx ->
match bx with {
case Box x -> Box (f x);
};
}
instance Applicative Box <= Functor Box where {
pure = \x -> Box x;
ap = \bf bx ->
match bf with {
case Box f -> map f bx;
};
}
Try:
type Box a = Box a;
instance Functor Box where {
map = \f bx ->
match bx with {
case Box x -> Box (f x);
};
}
instance Applicative Box <= Functor Box where {
pure = \x -> Box x;
ap = \bf bx ->
match bf with {
case Box f -> map f bx;
};
}
ap (Box ((*) 2)) (Box 21)
Step 4: make it a Monad
type Box a = Box a;
instance Functor Box where {
map = \f bx ->
match bx with {
case Box x -> Box (f x);
};
}
instance Applicative Box <= Functor Box where {
pure = \x -> Box x;
ap = \bf bx ->
match bf with {
case Box f -> map f bx;
};
}
instance Monad Box <= Applicative Box where {
bind = \f bx ->
match bx with {
case Box x -> f x;
};
}
Try:
type Box a = Box a;
instance Functor Box where {
map = \f bx ->
match bx with {
case Box x -> Box (f x);
};
}
instance Applicative Box <= Functor Box where {
pure = \x -> Box x;
ap = \bf bx ->
match bf with {
case Box f -> map f bx;
};
}
instance Monad Box <= Applicative Box where {
bind = \f bx ->
match bx with {
case Box x -> f x;
};
}
bind (\x -> Box (x + 1)) (Box 41)
Common pitfalls
- Overlapping instances: Rex rejects overlap for the same class; keep instance heads distinct.
- Missing context constraints: if your method body calls another overloaded method, you often need to list the required class in the instance context.
- Wrong argument order for
bind: Rex’sbindis(a -> m b)first, thenm a.
Default: Typeclass and Defaulting
In Rex, “default” can mean two different things:
- The
Defaulttypeclass methoddefault : a - The defaulting pass that resolves ambiguous type variables for defaultable classes
1) Default Typeclass (default : a)
The prelude provides Default and a set of built-in instances.
Built-in types with Default:
Boolu8,u16,u32,u64i8,i16,i32,i64f32,f64StringList aOption aResult a e(whenDefault ais available)
Implementing Default for custom ADTs
You can implement Default for many ADT shapes.
Single constructor with unnamed fields:
type Pair = Pair i32 Bool;
instance Default Pair where {
default = Pair 42 true;
}
Single constructor with named fields:
type Config = Config { retries: i32, enabled: Bool };
instance Default Config where {
default = Config { retries = 3, enabled = false };
}
Multiple variants (enum) with no fields:
type Mode = Fast | Safe | Debug;
instance Default Mode where {
default = Safe;
}
Multiple variants with mixed payload shapes:
type Token = Eof | IntLit i32 | Meta { line: i32, col: i32 };
instance Default Token where {
default = Meta { line = 1, col = 1 };
}
Generic ADTs with constraints:
type Box a = Box a | Missing;
instance<a> Default (Box a) <= Default a where {
default = Box default;
}
Constructing named options with selected overrides
For a single-constructor ADT with named fields, the constructor itself can request the type’s default and override only the fields you supply:
type Config = Config { retries: i32, enabled: Bool, label: String };
instance Default Config where {
default = Config { retries = 3, enabled = false, label = "standard" };
}
Config { retries = 9 }
This evaluates to a Config with retries = 9, enabled = false, and label = "standard".
Use Config {} when you want the default value while keeping its type explicit.
Omitted fields come from Default Config as a whole. Rex does not default each field separately,
so manually chosen and interdependent defaults are preserved. Complete construction still works
without a Default instance, while partial construction requires one.
This shorthand is intentionally limited to single-variant record ADTs. For an ADT with multiple variants, a type-level default might produce a different variant from the constructor you named.
Ambiguous default calls and is
When multiple Default instances are in scope, default may be ambiguous until you pin the type.
Record updates require a definitely known base type.
Failing example:
type A = A { x: i32, y: i32 };
type B = B { x: i32, y: i32 };
instance Default A where {
default = A { x = 1, y = 2 };
}
instance Default B where {
default = B { x = 10, y = 20 };
}
{ default with { x = 9 } }
In the editor/playground, use the quick fix on this error to insert is for the intended ADT.
Try it on the example above.
Passing example (same setup, with explicit is):
type A = A { x: i32, y: i32 };
type B = B { x: i32, y: i32 };
instance Default A where {
default = A { x = 1, y = 2 };
}
instance Default B where {
default = B { x = 10, y = 20 };
}
{ (default is A) with { x = 9 } }
Another failing example (same ambiguity in a let binding):
type A = A { x: i32, y: i32 };
type B = B { x: i32, y: i32 };
instance Default A where {
default = A { x = 1, y = 2 };
}
instance Default B where {
default = B { x = 10, y = 20 };
}
let
a = { default with { x = 9 } },
b = { default with { y = 8 } }
in
(a, b)
For this let-binding form, quick fixes offer two styles: add is to the
default call, or add a type annotation on the binding (for example
a: A = ...). These same quick fixes are also exposed through LSP code
actions and can be used by LLM-driven tooling.
2) Type Defaulting (Ambiguous Types)
Some overloaded prelude operations (such as zero) only require class constraints:
zero
If nothing else forces a concrete type, Rex runs defaulting to pick a concrete type from the defaulting candidates that satisfy the required class constraints.
If you see an “ambiguous overload” error around numeric expressions, force a type:
let z: i32 = zero in z
Or use the value in a way that constrains it:
zero + 1
Integer literals are also overloaded (over Integral) and become concrete from context:
let
x = 4,
f: u16 -> u16 = \n -> n
in
f x
Negative literals must resolve to a signed type:
let
x: i32 = -3,
f: i32 -> i32 = \n -> n
in
f x
let x: u32 = -3 in x is a type error.
The defaulting algorithm is specified in Specification (“Defaulting”).
Higher-Kinded Types (and Partial Application)
This page explains the “advanced” type shape behind Functor, Applicative, and Monad.
What is a “type constructor”?
Some types take type parameters:
List aOption aPromise aResult a e
The bare names List, Option, and Promise are type constructors (they still need an a).
In informal kind notation:
List : * -> *Option : * -> *Promise : * -> *Result : * -> * -> *
Why Functor talks about f a
The class is defined as:
class Functor f where {
map<a,b> : (a -> b) -> f a -> f b;
}
f here stands for a unary type constructor like List, Option, or Promise.
Result is binary — so how can it be a Functor?
The prelude has an instance:
instance<e> Functor (Result e) where {
map = prim_map;
}
Result e means: “fix the error type to e, leaving one type parameter for the Ok value”.
Written fully (with both parameters), that’s Result a e.
So Result e behaves like a unary type constructor:
Result a eis “a result withOktypeaandErrtypee”maptransforms theOkvalue and leavesErralone
Recognizing partial application in types
Whenever you see something like (Result e) or (Either e) in other languages, think:
“We pinned one type parameter to turn a multi-parameter type into a unary constructor.”
This idea shows up again for Applicative (Result e) and Monad (Result e).
Section 3 — Worked Examples
These examples are interactive in the docs: edit and run them directly on each page.
Tip: You can also run many examples inline with:
cargo run -p rex-cli --bin rex_cli -- -c '<paste rex expression here>'For larger multi-line experiments, saving to a
.rexfile is often easier.
Chapters
- Lists
- Folds
- Match and ADTs
- Records
- Functor Polymorphism
- Option Pipelines
- Result Workflows
- Custom Show Printing
- Custom Size
- List Helpers
- Small Standard Module
- Mini Project
Example: List Basics
This page is a hand-held tour of the most common list workflow in Rex:
- start with a list value
- transform it with
map - keep only what you want with
filter - (optionally) reduce it with
foldl(see the next page)
If you’re new to functional programming, think of map as “for each element, compute a new
element”, and filter as “keep only elements where the predicate is true”.
Goal
Take a list of integers and produce new lists by applying simple rules (double, keep even, increment).
The simplest transform
Double everything
map ((*) 2) [1, 2, 3, 4]
What to notice
mapcomes fromFunctor List.(*)is just a function;((*) 2)is a partially applied function.
Step-by-step: naming intermediate values
The one-liner above is idiomatic, but while learning it helps to name each step:
let
xs = [1, 2, 3, 4],
doubled = map ((*) 2) xs
in
doubled
This style also makes it easier to debug by temporarily returning an intermediate.
Filter then map
Filtering needs a predicate a -> Bool. Let’s define one:
let
is_even = \x -> (x % 2) == 0
in
filter is_even [1, 2, 3, 4, 5, 6]
Now combine filter and map:
let
xs = [1, 2, 3, 4, 5, 6],
is_even = \x -> (x % 2) == 0
in
map ((+) 1) (filter is_even xs)
Variations
Try changing the predicate to keep odd numbers instead:
let is_odd = \x -> (x % 2) != 0 in filter is_odd [1, 2, 3, 4, 5, 6]
Common beginner mistake: missing parentheses
Because application is left-associative, nesting calls without parentheses does not do what you want. Prefer:
let
xs = [1, 2, 3, 4, 5, 6],
is_even = \x -> (x % 2) == 0
in
map ((+) 1) (filter is_even xs)
over trying to “read it as English” without grouping.
Worked examples
Example: triple_then_keep_big
Problem: triple each element, then keep only elements greater than 10.
let
xs = [1, 2, 3, 4, 5],
tripled = map ((*) 3) xs
in
filter (\x -> x > 10) tripled
Why this works: map ((*) 3) transforms each element first, then filter keeps only values that
pass the predicate.
Example: between lo hi x with filter
Problem: keep only values in an inclusive range.
let
between = \lo hi x -> x >= lo && x <= hi
in
filter (between 3 5) [1, 2, 3, 4, 5, 6]
Why this works: between 3 5 is a predicate function i32 -> Bool, which is exactly what
filter expects.
Example: naming inc in let
Problem: replace ((+) 1) with a named helper.
let
inc = \x -> x + 1
in
map inc [1, 2, 3, 4]
Why this works: inc has type i32 -> i32, so it can be passed directly to map.
Example: Folding
Foldable gives you foldl and foldr-style iteration.
If map changes values, folds reduce a collection down to a single result.
Goal
Learn to take a list and reduce it to:
- a number (sum, product, count)
- a string (joining)
A mental model: accumulator + step
foldl has the shape:
foldl : (b -> a -> b) -> b -> t a -> b
Read it as:
- Start with an accumulator of type
b - For each element of type
a, update the accumulator - Return the final accumulator
Sum a list
foldl (+) 0 [1, 2, 3, 4]
How to read it
- Start with accumulator
0 - For each element, add it to the accumulator
- Return the final accumulator
The same thing, spelled out
let
step = \acc x -> acc + x
in
foldl step 0 [1, 2, 3, 4]
When debugging, spelling out step makes it easier to reason about types.
Build a string
let
step = \out x ->
if out == "" then x else out + ", " + x
in
foldl step "" ["a", "b", "c"]
Worked example: bracketed join
Problem: join strings with commas and wrap the result in brackets.
let
step = \out x ->
if out == "" then x else out + ", " + x,
joined = foldl step "" ["a", "b", "c"]
in
"[" + joined + "]"
Why this works: the fold builds "a, b, c" from left to right, then the final expression adds the
outer brackets.
Using folds to compute “length”
You can compute list length by ignoring elements and incrementing a counter:
foldl (\n _ -> n + 1) 0 [10, 20, 30, 40]
When to prefer match recursion vs foldl
Both are fine. Rules of thumb:
- Use
foldlwhen you’re “reducing” to a single value (sum, count, join). - Use explicit
matchrecursion when you need more complex control flow.
Worked examples
Example: product with foldl
Problem: multiply all numbers in a list, starting from 1.
foldl (*) 1 [2, 3, 4]
Why this works: 1 is the multiplicative identity, so each element is accumulated by multiplication.
Example: all over booleans
Problem: check whether every boolean in a list is true.
let
all = \xs -> foldl (\acc x -> acc && x) true xs
in
(all [true, true, true], all [true, false, true])
Why this works: once acc becomes false, acc && x stays false for the rest of the fold.
Example: any over booleans
Problem: check whether at least one boolean in a list is true.
let
any = \xs -> foldl (\acc x -> acc || x) false xs
in
(any [false, false, true], any [false, false, false])
Why this works: the accumulator starts false and flips to true as soon as any element is true.
Example: ADTs + match
This example shows the usual pattern:
- define an ADT
- consume it with
match
Goal
Build a tiny “Maybe” API:
fromMaybe(extract with default)mapMaybe(transform insideJust)isJust(check which constructor you have)
Define an ADT
type Maybe a = Just a | Nothing;
Use it
type Maybe a = Just a | Nothing;
let
fromMaybe = \d m ->
match m with {
case Just x -> x;
case Nothing -> d;
}
in
( fromMaybe 0 (Just 5)
, fromMaybe 0 Nothing
)
Next steps
The next example implements mapMaybe, which applies a function to Just x and leaves
Nothing unchanged.
A worked mapMaybe
type Maybe a = Just a | Nothing;
let
mapMaybe = \f m ->
match m with {
case Just x -> Just (f x);
case Nothing -> Nothing;
}
in
( mapMaybe ((+) 1) (Just 41)
, mapMaybe ((+) 1) Nothing
)
Testing constructors with match
There’s no special “isJust” operator — you write it with match:
type Maybe a = Just a | Nothing;
let
isJust = \m ->
match m with {
case Just _ -> true;
case Nothing -> false;
}
in
(isJust (Just 1), isJust Nothing)
Common mistake: missing arms
When matching on an ADT, Rex checks exhaustiveness. If you forget an arm, you’ll get an error that names the missing constructors.
Worked examples
Example: orElse
Problem: return the first Just value, otherwise return the fallback Maybe.
type Maybe a = Just a | Nothing;
let
orElse = \ma mb ->
match ma with {
case Just x -> Just x;
case Nothing -> mb;
}
in
(orElse (Just 1) (Just 2), orElse Nothing (Just 2))
Why this works: match chooses ma when it is Just, and only uses mb when ma is Nothing.
Example: andThen (Maybe bind)
Problem: chain a function that returns Maybe, failing early on Nothing.
type Maybe a = Just a | Nothing;
let
andThen = \f ma ->
match ma with {
case Just x -> f x;
case Nothing -> Nothing;
},
step = \x -> if x > 0 then Just (x + 1) else Nothing
in
(andThen step (Just 3), andThen step Nothing, andThen step (Just (0 - 1)))
Why this works: Just unwraps and continues with f; Nothing short-circuits immediately.
Example: adding Unknown and handling exhaustiveness
Problem: extend Maybe and still keep matches exhaustive.
type Maybe a = Just a | Nothing | Unknown;
let
fromMaybe = \d m ->
match m with {
case Just x -> x;
case Nothing -> d;
case Unknown -> d;
}
in
(fromMaybe 0 (Just 5), fromMaybe 0 Unknown)
Why this works: the Unknown arm makes the match complete for all constructors.
Example: Records and Updates
This example focuses on record-carrying ADTs, because that’s where you most often use:
- projection (
x.field) - update (
{ x with { field = ... } })
Goal
Model a User record, read its fields, and produce an updated copy.
Step 1: define and update
type User = User { name: String, age: i32 };
let
u: User = User { name = "Ada", age = 36 },
older = { u with { age = u.age + 1 } }
in
(u.age, older.age)
What to notice
User { ... }constructs a record-carrying ADT value.u.ageis field projection.{ u with { age = ... } }updates the record payload and re-wraps the constructor.
Step 2: update multiple fields
type User = User { name: String, age: i32 };
let
u: User = User { name = "Ada", age = 36 },
updated =
{ u with
{ age = u.age + 1
, name = u.name + "!"
}
}
in
(u, updated)
Step 3: why match sometimes matters
Projection/update is only allowed when a field is definitely available on the type. With a
multi-variant ADT, you often refine it with match first:
type Sum = A { x: i32 } | B { x: i32 };
let s: Sum = A { x = 1 } in
match s with {
case A {x} -> { s with { x = x + 1 } };
case B {x} -> { s with { x = x + 2 } };
}
Worked examples
Example: birthday applied twice
Problem: define birthday : User -> User and apply it two times.
type User = User { name: String, age: i32 };
let
birthday = \u -> { u with { age = u.age + 1 } },
u0: User = User { name = "Ada", age = 36 },
u2 = birthday (birthday u0)
in
(u0.age, u2.age)
Why this works: each birthday call returns a new User with age incremented by one.
Example: add admin and promote
Problem: add a boolean field and set it to true.
type User = User { name: String, age: i32, admin: Bool };
let
promote = \u -> { u with { admin = true } },
u0: User = User { name = "Ada", age = 36, admin = false }
in
promote u0
Why this works: record update changes only admin, preserving other fields.
Example: add constructor C and update the match
Problem: extend Sum with C { x: i32 } and keep updates valid.
type Sum = A { x: i32 } | B { x: i32 } | C { x: i32 };
let s: Sum = C { x = 1 } in
match s with {
case A {x} -> { s with { x = x + 1 } };
case B {x} -> { s with { x = x + 2 } };
case C {x} -> { s with { x = x + 3 } };
}
Why this works: each arm refines s to a definite constructor, so the update is type-safe.
Example: One map, many containers
This demonstrates deferred resolution of a Functor method value.
Goal
Understand why one definition of f can work for lists, options, and results, and how to avoid
ambiguity when working with overloaded methods.
let f = map ((+) 1) in
( f [1, 2, 3]
, f (Some 41)
, f (Ok 21)
)
Why this is cool
f is a single definition that can be applied to different container types. Rex defers selecting
the Functor instance until you apply f to a concrete container.
Step-by-step
Start by binding the method value:
let f = map ((+) 1) in f
At this point, f is still a function, so Rex can keep it “overloaded”.
Now apply it to a list:
let f = map ((+) 1) in f [1, 2, 3]
At this call site, f must be List i32 -> List i32, so Rex selects Functor List.
Contrast: ambiguous non-function values
Some overloaded values are ambiguous if you don’t force a type. For example, pure 1 could be a
List i32, Option i32, Result i32 e, etc.
Fix it by forcing a type:
let x: Option i32 = pure 1 in x
Worked examples
Example: mapping over Err
Problem: verify that map does not change the error branch.
map ((+) 1) (Err "boom")
Why this works: Functor (Result e) maps only the Ok value, leaving Err unchanged.
Example: one g, list and option
Problem: define g = map (\x -> x * x) once and apply it to multiple containers.
let g = map (\x -> x * x) in
(g [1, 2, 3], g (Some 4))
Why this works: instance resolution for map is deferred until each concrete call site.
Example: fixing ambiguous pure 1
Problem: choose a concrete container for pure 1.
let x: Option i32 = pure 1 in x
Why this works: the annotation forces pure to use the Applicative Option instance.
Example: Option pipelines (Applicative + Monad)
Option a represents “a value that might not exist”.
Goal
Write a multi-step computation that:
- fails early by producing
None - otherwise returns a final value wrapped in
Some
Then rewrite it in three styles:
- plain
match bindchaining (monadic)apapplication (applicative)
Applicative: apply a wrapped function
ap (Some ((*) 2)) (Some 21)
If either side is None, the result is None.
Monad: sequence steps with bind
let
step1 = \x -> if x < 0 then None else Some (x + 1),
step2 = \x -> Some (x * 2)
in
bind step2 (bind step1 (Some 10))
Refactoring tip
If you have many steps, name them:
let
step1 = \x -> if x < 0 then None else Some (x + 1),
step2 = \x -> Some (x * 2),
run = \x -> bind step2 (bind step1 x)
in
run (Some 10)
The same logic using match
bind is convenience. Under the hood, it’s the same “if None, stop” flow you would write with
match:
let
step1 = \x -> if x < 0 then None else Some (x + 1),
step2 = \x -> Some (x * 2)
in
match (Some 10) with {
case None -> None;
case Some v1 ->
match (step1 v1) with {
case None -> None;
case Some v2 -> step2 v2;
};
}
When to use ap vs bind
- Use
apwhen you have independent optional pieces and want to apply a function if all exist. - Use
bindwhen the next step depends on the previous result.
Worked examples
Example: fail step1 on x == 0 too
Problem: update step1 so non-positive input fails.
let
step1 = \x ->
if x < 0 then None else
if x == 0 then None else
Some (x + 1)
in
(step1 10, step1 0, step1 (0 - 1))
Why this works: the additional guard handles zero before success.
Example: add2opt via ap and pure
Problem: add two optional integers when both are present.
let
add2opt = \ox oy -> ap (ap (pure (\x y -> x + y)) ox) oy
in
(add2opt (Some 1) (Some 2), add2opt None (Some 2))
Why this works: pure lifts the function, then each ap applies one argument inside Option.
Example: validate list values with filter_map
Problem: keep only non-negative values and increment them.
let
validate = \x -> if x < 0 then None else Some (x + 1)
in
filter_map validate [3, (0 - 1), 0, 5]
Why this works: filter_map drops None results and unwraps Some values into the output list.
Example: Result workflows
Result a e short-circuits on Err.
Goal
Model a computation that can fail with a useful error, while keeping “happy path” code easy to read.
let
step1 = \x -> if x < 0 then Err "negative" else Ok (x + 1),
step2 = \x -> Ok (x * 2)
in
( bind step2 (bind step1 (Ok 10))
, bind step2 (bind step1 (Ok (0 - 1)))
)
What to notice
- When
step1returnsErr, the secondbindis skipped. - This lets you write “happy path” code without deeply nested
match.
Step-by-step: name the pipeline
let
step1 = \x -> if x < 0 then Err "negative" else Ok (x + 1),
step2 = \x -> Ok (x * 2),
run = \x -> bind step2 (bind step1 x)
in
(run (Ok 10), run (Ok (0 - 1)))
map vs bind
Use map when your function does not fail and does not change the container:
map ((+) 1) (Ok 41)
Use bind when your function returns another Result (and might fail):
bind (\x -> if x < 0 then Err "negative" else Ok x) (Ok 1)
Worked examples
Example: fail step2 when value is too large
Problem: make the second step return Err above a threshold.
let
step1 = \x -> if x < 0 then Err "negative" else Ok (x + 1),
step2 = \x -> if x > 20 then Err "too-large" else Ok (x * 2),
run = \x -> bind step2 (bind step1 x)
in
(run (Ok 10), run (Ok 25))
Why this works: bind short-circuits on either error source, including the new step2 condition.
Example: custom error ADT
Problem: replace string errors with structured errors.
type Err = Negative | TooLarge;
let
step1 = \x -> if x < 0 then Err Negative else Ok (x + 1),
step2 = \x -> if x > 20 then Err TooLarge else Ok (x * 2),
run = \x -> bind step2 (bind step1 x)
in
(run (Ok 10), run (Ok 25), run (Ok (0 - 1)))
Why this works: error constructors carry precise machine-readable failure categories.
Example: and_then synonym
Problem: define a helper that reads like “then”.
let
and_then = \mx f -> bind f mx,
safe_inc = \x -> if x < 0 then Err "negative" else Ok (x + 1)
in
and_then (Ok 1) safe_inc
Why this works: and_then is just argument-reordered bind, so behavior is identical.
Example: Custom Show type class
This mirrors rex-cli/examples/typeclasses_custom_show.rex.
Goal
Define your own show-printing API that turns values into String without baking formatting into
every call site.
We’ll build it up in layers:
- define the class
- add a base instance (
i32) - add a structured type (
Point) - (optional) add a container instance (
List a)
class DemoShow a where {
demo_show : a -> String;
}
type Point = Point { x: i32, y: i32 };
instance DemoShow i32 where {
demo_show = \_ -> "<i32>";
}
instance DemoShow Point where {
demo_show = \p -> "Point(" + demo_show p.x + ", " + demo_show p.y + ")";
}
demo_show (Point { x = 1, y = 2 })
Extending it
Add an instance DemoShow (List a) <= DemoShow a (see rex-cli/examples/typeclasses_custom_show.rex)
and call demo_show [Point { x = 1, y = 2 }].
A worked DemoShow (List a) instance
Here is the list instance from the repo example, with commentary:
class DemoShow a where {
demo_show : a -> String;
}
instance DemoShow i32 where {
demo_show = \_ -> "<i32>";
}
instance<a> DemoShow (List a) <= DemoShow a where {
demo_show = \xs ->
let
step = \out x ->
if out == "["
then out + demo_show x
else out + ", " + demo_show x,
out = foldl step "[" xs
in
out + "]";
}
Why the <= DemoShow a constraint?
Because the implementation calls demo_show x for list elements, so it requires DemoShow a.
Worked examples
Example: use "; " as the list separator
Problem: format list output with semicolons.
class DemoShow a where {
demo_show : a -> String;
}
instance DemoShow i32 where {
demo_show = \_ -> "<i32>";
}
instance<a> DemoShow (List a) <= DemoShow a where {
demo_show = \xs ->
let
step = \out x ->
if out == "["
then out + demo_show x
else out + "; " + demo_show x,
out = foldl step "[" xs
in
out + "]";
}
demo_show [1, 2, 3]
Why this works: only the separator string changed; the fold structure stays the same.
Example: DemoShow Bool
Problem: add show-print support for booleans.
class DemoShow a where {
demo_show : a -> String;
}
instance DemoShow Bool where {
demo_show = \b -> if b then "true!" else "false!";
}
(demo_show true, demo_show false)
Why this works: the instance defines one method body specialized to Bool.
Example: DemoShow (Option a)
Problem: print Some(...) and None for options.
class DemoShow a where {
demo_show : a -> String;
}
instance DemoShow i32 where {
demo_show = \_ -> "<i32>";
}
instance<a> DemoShow (Option a) <= DemoShow a where {
demo_show = \ox ->
match ox with {
case Some x -> "Some(" + demo_show x + ")";
case None -> "None";
};
}
(demo_show (Some 1), demo_show None)
Why this works: pattern matching distinguishes constructors and delegates formatting of payloads.
Example: Custom Size type class
This mirrors rex-cli/examples/typeclasses_custom_size.rex.
Goal
Use a type class to define a common “size” operation across different data types, without hard-coding the type at every call site.
class Size a where {
size : a -> i32;
}
type Blob = Blob { bytes: List i32 };
instance<t> Size (List t) where {
size = \xs ->
match xs with {
case Empty -> 0;
case Cons _ t -> 1 + size t;
};
}
instance Size Blob where {
size = \b -> size b.bytes;
}
size (Blob { bytes = [1, 2, 3, 4] })
What this demonstrates
- defining a class with one method,
- writing an instance for a prelude type (
List), - writing an instance for your own ADT (
Blob), - using
matchfor recursion.
Calling size generically
Once you have a class, you can write functions that work for any type that has an instance:
class Size a where {
size : a -> i32;
}
instance<t> Size (List t) where {
size = \xs ->
match xs with {
case Empty -> 0;
case Cons _ t -> 1 + size t;
};
}
let
bigger<a>: a -> i32 = \(x: a) where Size a -> size x + 1
in
bigger [1, 2, 3]
The where Size a constraint says: “this function is valid as long as Size a exists”.
Worked examples
Example: is_empty
Problem: write a generic emptiness check from size.
class Size a where {
size : a -> i32;
}
instance<t> Size (List t) where {
size = \xs ->
match xs with {
case Empty -> 0;
case Cons _ t -> 1 + size t;
};
}
let
is_empty<a>: a -> Bool = \(x: a) where Size a -> size x == 0
in
(is_empty ([] is List i32), is_empty [1, 2, 3])
Why this works: any type with a Size instance can reuse the same is_empty logic.
Example: Blob with name
Problem: add a name field and keep size based on bytes only.
class Size a where {
size : a -> i32;
}
instance<t> Size (List t) where {
size = \xs ->
match xs with {
case Empty -> 0;
case Cons _ t -> 1 + size t;
};
}
type Blob = Blob { name: String, bytes: List i32 };
instance Size Blob where {
size = \b -> size b.bytes;
}
size (Blob { name = "payload", bytes = [1, 2, 3, 4] })
Why this works: Size Blob delegates to the bytes list, so metadata does not affect size.
Example: total_size
Problem: sum sizes of a list of values.
class Size a where {
size : a -> i32;
}
instance<t> Size (List t) where {
size = \xs ->
match xs with {
case Empty -> 0;
case Cons _ t -> 1 + size t;
};
}
let
total_size = \(xs: List a) where Size a ->
foldl (\acc x -> acc + size x) 0 xs
in
total_size [[1, 2], [], [3]]
Why this works: foldl accumulates per-element sizes using the shared Size a method.
Example: List Helpers
List-specific helpers complement the generic map, filter, folds, and Sequence operations.
They use u64 positions and return Option when indexing or slicing can fail.
Safe access
( list_get 1 [10, 20, 30]
, list_get 3 [10, 20, 30]
, list_slice 1 3 [10, 20, 30]
, list_slice 3 1 [10, 20, 30]
)
list_get uses a zero-based index. list_slice uses a half-open range and returns None when
either bound is invalid or the end precedes the start.
Constructing and reshaping lists
( list_reverse [1, 2, 3]
, list_concat [[1, 2], [], [3, 4]]
, list_repeat 3 "rex"
)
list_concat removes exactly one layer of nesting. list_repeat 0 value returns an empty list.
Searching
let even = \x -> x % 2 == 0 in
( list_any even [1, 3, 4, 5]
, list_all even [2, 4, 6]
, list_find even [1, 3, 4, 6]
, list_find_index even [1, 3, 4, 6]
, list_count even [1, 2, 3, 4, 5, 6]
)
The any, all, and find helpers inspect elements from left to right and stop when their answer is
known. The find helpers return the first match. On an empty list, list_any is false, list_all
is true, and both find helpers return None.
Partitioning
list_partition (\x -> x < 0) [3, negate 1, 0, negate 4, 2]
The first output list contains matching elements and the second contains rejected elements. Both retain their relative order from the input.
Example: Small Standard Module Helpers
It’s common to build small helpers with let and reuse them.
Goal
Practice building tiny “glue” functions that keep your code readable, especially when composing many operations.
let
compose = \f g x -> f (g x),
inc = \x -> x + 1,
double = \x -> x * 2,
inc_then_double = compose double inc
in
inc_then_double 10
Worked example: double_then_inc
Problem: define double_then_inc and show it differs from inc_then_double.
let
compose = \f g x -> f (g x),
inc = \x -> x + 1,
double = \x -> x * 2,
inc_then_double = compose double inc,
double_then_inc = compose inc double
in
(inc_then_double 10, double_then_inc 10)
Why this works: function composition order changes the final result (22 vs 21).
A more “pipeline” style
Sometimes it’s clearer to read left-to-right:
let
pipe = \x f -> f x,
pipe2 = \x f g -> g (f x),
inc = \x -> x + 1,
double = \x -> x * 2
in
pipe2 10 inc double
This is the same logic as double (inc 10), just easier to extend when you have many steps.
Worked examples
Example: pipe3
Problem: apply three transforms in left-to-right style.
let
pipe3 = \x f g h -> h (g (f x)),
inc = \x -> x + 1,
double = \x -> x * 2,
square = \x -> x * x
in
pipe3 3 inc double square
Why this works: each function consumes the previous output, so the pipeline is explicit.
Example: compose square then add one
Problem: build a reusable function that squares and then increments.
let
compose = \f g x -> f (g x),
square = \x -> x * x,
add1 = \x -> x + 1,
square_then_add1 = compose add1 square
in
square_then_add1 5
Why this works: compose add1 square creates \x -> add1 (square x).
Example: map a composed function
Problem: combine map with composition for list transforms.
let
compose = \f g x -> f (g x),
square = \x -> x * x,
add1 = \x -> x + 1,
square_then_add1 = compose add1 square
in
map square_then_add1 [1, 2, 3, 4]
Why this works: one composed function encapsulates the per-element transform applied by map.
Mini Project: Validating and Transforming Records
This example combines records, match, and Result.
Goal
Build a small “workflow” in Rex:
- validate a
User - transform it (birthday)
- return either a useful error or the transformed user
This is a pattern you can scale up: each step is a function returning a Result, and you connect
steps with bind.
type User = User { name: String, age: i32 };
let
validate = \u ->
if u.age < 0
then Err "age must be non-negative"
else Ok u,
birthday = \u -> { u with { age = u.age + 1 } }
in
bind (\u -> Ok (birthday u)) (validate (User { name = "Ada", age = 36 }))
Worked extensions
Example: update birthday to change name too
Problem: increment age and append "!" to the user name in one transform.
type User = User { name: String, age: i32 };
let
birthday = \u ->
{ u with
{ age = u.age + 1
, name = u.name + "!"
}
}
in
birthday (User { name = "Ada", age = 36 })
Why this works: one record update can set multiple fields at once.
Example: reject empty names during validation
Problem: make validation fail when name == "".
type User = User { name: String, age: i32 };
let
validate = \u ->
if u.age < 0 then Err "age must be non-negative" else
if u.name == "" then Err "name must be non-empty" else
Ok u
in
( validate (User { name = "Ada", age = 36 })
, validate (User { name = "", age = 36 })
)
Why this works: the second guard introduces an additional failure branch before success.
Example: structured error ADT
Problem: replace free-form strings with typed error constructors.
type UserError = NegativeAge | EmptyName;
type User = User { name: String, age: i32 };
let
validate = \u ->
if u.age < 0 then Err NegativeAge else
if u.name == "" then Err EmptyName else
Ok u
in
validate (User { name = "", age = 36 })
Why this works: callers can pattern-match on error constructors without string parsing.
A worked “structured error” version
Instead of strings, define an error ADT:
type UserError = NegativeAge | EmptyName;
type User = User { name: String, age: i32 };
let
validate = \u ->
if u.age < 0 then Err NegativeAge else
if u.name == "" then Err EmptyName else
Ok u,
birthday = \u -> { u with { age = u.age + 1 } },
run = \u -> bind (\ok -> Ok (birthday ok)) (validate u)
in
( run (User { name = "Ada", age = 36 })
, run (User { name = "", age = 36 })
, run (User { name = "Ada", age = (0 - 1) })
)
What to notice
validatereturns early with the first error it finds.runusesbindto only callbirthdaywhen validation succeeded.
Worked examples
Example: add an upper-age validation rule
Problem: reject ages greater than 150.
type UserError = NegativeAge | EmptyName | TooOld;
type User = User { name: String, age: i32 };
let
validate = \u ->
if u.age < 0 then Err NegativeAge else
if u.age > 150 then Err TooOld else
if u.name == "" then Err EmptyName else
Ok u
in
( validate (User { name = "Ada", age = 36 })
, validate (User { name = "Ada", age = 200 })
)
Why this works: the additional guard catches out-of-range ages before success.
Example: chain a second transform step with bind
Problem: run two transforms (birthday then normalize_name) after validation.
type User = User { name: String, age: i32 };
let
validate = \u -> if u.age < 0 then Err "negative-age" else Ok u,
birthday = \u -> Ok ({ u with { age = u.age + 1 } }),
normalize_name = \u -> Ok ({ u with { name = u.name + "!" } }),
run = \u ->
bind normalize_name
(bind birthday
(validate u))
in
run (User { name = "Ada", age = 36 })
Why this works: each bind feeds a successful result into the next transform.
Example: split validation into smaller validators
Problem: compose independent validators with bind.
type User = User { name: String, age: i32 };
let
check_age = \u -> if u.age < 0 then Err "negative-age" else Ok u,
check_name = \u -> if u.name == "" then Err "empty-name" else Ok u,
validate = \u -> bind check_name (check_age u)
in
( validate (User { name = "Ada", age = 36 })
, validate (User { name = "", age = 36 })
)
Why this works: each validator has the same User -> Result User e shape, so they compose cleanly.
Interactive Demos
These demos are meant to be edited and run directly in the docs.
- Factorial — classic recursion over natural numbers
- Fibonacci — recursive sequence generation
- Merge Sort — classic divide-and-conquer sorting
- Binary Search Tree — insertion and lookup in a recursive ADT
- Expression Evaluator — evaluate a tiny arithmetic AST
- Dijkstra Lite — shortest-path relaxation on a tiny weighted graph
- 0/1 Knapsack — dynamic-programming row updates
- Union-Find — disjoint-set connectivity on a small graph
- Prefix Parser + Evaluator — recursive-descent parsing of prefix tokens
- Topological Sort — Kahn-style ordering on a DAG
- N-Queens — backtracking with diagonal pruning
Demo: Factorial
This demo computes n! using direct recursion: it multiplies n by the factorial of n - 1 until it reaches the base case 0, which returns 1. It is a minimal example of structural recursion over integers and shows how a simple mathematical definition maps directly to Rex function syntax.
Related reading: Factorial.
The implementation is centered on fact, which uses an if expression to separate the base case from the recursive case. The final line evaluates fact 6, so the output is a single integer result; this keeps the demo focused on call structure and termination rather than data modeling.
fn fact : i32 -> i32 = \n ->
if n == 0
then 1
else n * fact (n - 1);
fact 6
Demo: Fibonacci
This demo generates Fibonacci numbers with the classic recursive recurrence: each value is the sum of the previous two, with base cases at 0 and 1. It illustrates branching recursion and builds a small prefix of the sequence so you can see the growth pattern directly in the output.
Related reading: Fibonacci number.
The fib function mirrors the mathematical recurrence directly: when n is 0 or 1 it returns immediately, otherwise it performs two recursive calls and adds their results. Instead of evaluating just one input, the demo computes a list from fib 0 through fib 10, which makes it easy to confirm correctness across multiple cases in one run.
fn fib : i32 -> i32 = \n ->
if n <= 1
then n
else fib (n - 1) + fib (n - 2);
[fib 0, fib 1, fib 2, fib 3, fib 4, fib 5, fib 6, fib 7, fib 8, fib 9, fib 10]
Demo: Merge Sort
This demo implements merge sort, a divide-and-conquer algorithm that splits a list into halves, recursively sorts each half, and then merges the two sorted results. The implementation highlights recursive split_alt and merge helpers together with the prelude’s cmp function, and demonstrates how recursive decomposition can produce deterministic, stable ordering over immutable lists.
Related reading: Merge sort.
cmp returns the prelude’s Ordering ADT (Less, Equal, or Greater), and split_alt peels off pairs to partition input into two sublists without mutation. mergesort handles the empty and singleton base cases, then recursively sorts both halves and combines them with merge, which pattern-matches on two lists and emits the smaller head first. On equality, it takes from the left sublist first to keep the sort stable.
fn split_alt : List i32 -> (List i32, List i32) = \xs ->
match xs with {
case [] -> ([], []);
case [x] -> ([x], []);
case x::y::rest ->
let (xs1, ys1) = split_alt rest in (Cons x xs1, Cons y ys1);
};
fn merge : List i32 -> List i32 -> List i32 = \xs ys ->
match (xs, ys) with {
case ([], _) -> ys;
case (_, []) -> xs;
case (x::xt, y::yt) ->
match (cmp x y) with {
case Less -> Cons x (merge xt ys);
case Equal -> Cons x (merge xt ys);
case Greater -> Cons y (merge xs yt);
};
};
fn mergesort : List i32 -> List i32 = \xs ->
match xs with {
case [] -> [];
case [x] -> [x];
case _ ->
let (left, right) = split_alt xs in
merge (mergesort left) (mergesort right);
};
let
input = [9, 1, 7, 3, 2, 8, 6, 4, 5]
in
mergesort input
Demo: Binary Search Tree
This demo builds and queries a binary search tree, where values smaller than a node go left and larger values go right. It shows insertion, membership testing, and size calculation over a custom recursive ADT, illustrating how ordered data structures can be expressed with pure pattern-matching functions.
Related reading: Binary search tree.
The Tree type has Empty and Node constructors, and each operation recursively follows tree structure. insert descends left or right based on key order and rebuilds the path back up, contains follows the same branching logic to test membership, and size traverses both subtrees to count nodes; the final let block builds an example tree and returns a tuple of summary queries.
type Tree = Empty | Node { key: i32, left: Tree, right: Tree };
fn insert : i32 -> Tree -> Tree = \k t ->
match t with {
case Empty -> Node { key = k, left = Empty, right = Empty };
case Node {key, left, right} ->
if k < key then
Node { key = key, left = insert k left, right = right }
else if k > key then
Node { key = key, left = left, right = insert k right }
else
t;
};
fn contains : i32 -> Tree -> Bool = \k t ->
match t with {
case Empty -> false;
case Node {key, left, right} ->
if k == key then true
else if k < key then contains k left
else contains k right;
};
fn size : Tree -> i32 = \t ->
match t with {
case Empty -> 0;
case Node {left, right} -> 1 + size left + size right;
};
let
t0: Tree = Empty
in
let
t1 = insert 7 (insert 2 (insert 9 (insert 1 (insert 5 t0)))),
t2 = insert 8 t1
in
(size t2, contains 5 t2, contains 4 t2)
Demo: Expression Evaluator
This demo evaluates arithmetic expressions represented as an AST with constructors for literals, addition, multiplication, and negation. Alongside evaluation, it computes expression depth and performs a small simplification pass, showing how one tree structure can support multiple independent recursive analyses and transformations.
Related reading: Abstract syntax tree.
The code defines one ADT (Expr) and then reuses it across three traversals: eval computes numeric meaning, depth computes structural height, and simplify_once applies a local rewrite rule for double negation. In the final expression block, two sample trees are built, one is simplified once, and the output tuple shows evaluation and depth results side-by-side.
type Expr = Lit i32 | Add Expr Expr | Mul Expr Expr | Neg Expr;
fn eval : Expr -> i32 = \e ->
match e with {
case Lit n -> n;
case Add a b -> eval a + eval b;
case Mul a b -> eval a * eval b;
case Neg x -> 0 - eval x;
};
fn depth : Expr -> i32 = \e ->
match e with {
case Lit _ -> 1;
case Add a b ->
if depth a > depth b then 1 + depth a else 1 + depth b;
case Mul a b ->
if depth a > depth b then 1 + depth a else 1 + depth b;
case Neg x -> 1 + depth x;
};
fn simplify_once : Expr -> Expr = \e ->
match e with {
case Neg (Neg x) -> x;
case _ -> e;
};
let
expr1 = Add (Lit 2) (Mul (Lit 3) (Lit 4)),
expr2 = Neg (Neg (Add (Lit 5) (Mul (Lit 1) (Lit 9))))
in
let simplified = simplify_once expr2 in
(eval expr1, depth expr1, eval simplified)
Demo: Dijkstra Lite
This demo models a minimal shortest-path problem and applies the core relaxation idea behind Dijkstra-style algorithms: compare a direct edge with an indirect path through an intermediate node and keep the smaller distance. It uses a small Dist ADT (Inf or Finite) to make unreachable and reachable cases explicit and type-safe.
Related reading: Dijkstra’s algorithm.
add_weight and min_dist isolate the distance algebra, so shortest_a_to_b can read clearly as “direct path versus via-C path, then pick minimum.” The sample graphs in the final let block exercise both outcomes: one where routing through C wins and one where the direct edge is best, with as_i32 converting the ADT into plain output numbers for display.
type Dist = Inf | Finite i32;
type Graph = Graph { ab: i32, ac: i32, cb: i32 };
fn add_weight : Dist -> i32 -> Dist = (\d w ->
match d with {
case Inf -> Inf;
case Finite x -> Finite (x + w);
}
);
fn min_dist : Dist -> Dist -> Dist = (\a b ->
match (a, b) with {
case (Inf, x) -> x;
case (x, Inf) -> x;
case (Finite x, Finite y) ->
if x <= y then Finite x else Finite y;
}
);
fn shortest_a_to_b : Graph -> Dist = (\g ->
let
direct = Finite g.ab,
via_c = add_weight (Finite g.ac) g.cb
in
min_dist direct via_c
);
fn as_i32 : Dist -> i32 = (\d ->
match d with {
case Inf -> -1;
case Finite x -> x;
}
);
let
g1 = Graph { ab = 10, ac = 3, cb = 4 },
g2 = Graph { ab = 5, ac = 9, cb = 9 },
d1 = shortest_a_to_b g1,
d2 = shortest_a_to_b g2
in
(as_i32 d1, as_i32 d2)
Demo: 0/1 Knapsack
This demo solves the 0/1 knapsack optimization problem with dynamic programming: each item can be taken at most once, and the algorithm computes the best achievable value for every capacity from 0 to max_cap. The table is represented as immutable rows, and each new row is derived from the previous one by choosing between “take” and “skip” for the current item.
Related reading: Knapsack problem.
zeros initializes the base DP row, build_row computes one new row for a single item, and go folds this process across the full item list. For each capacity, build_row compares without (skip item) and with_item (take item plus best compatible remainder), then stores the maximum; solve returns the final cell at max_cap.
type Item = Item { w: i32, v: i32 };
fn nth : List i32 -> i32 -> i32 = \xs i ->
match xs with {
case [] -> 0;
case x::rest ->
if i == 0 then x else nth rest (i - 1);
};
fn zeros : i32 -> i32 -> List i32 = \i max_cap ->
if i > max_cap then [] else Cons 0 (zeros (i + 1) max_cap);
fn build_row : Item -> List i32 -> i32 -> i32 -> List i32 = \item prev cap max_cap ->
if cap > max_cap then
[]
else
let
without = nth prev cap,
with_item =
if item.w <= cap then
item.v + nth prev (cap - item.w)
else
0,
best = if without >= with_item then without else with_item
in
Cons best (build_row item prev (cap + 1) max_cap);
fn go : List Item -> List i32 -> i32 -> List i32 = \remaining row max_cap ->
match remaining with {
case [] -> row;
case item::rest ->
let next = build_row item row 0 max_cap in
go rest next max_cap;
};
fn solve : List Item -> i32 -> i32 = \items max_cap ->
nth (go items (zeros 0 max_cap) max_cap) max_cap;
let
items = [
Item { w = 2, v = 3 },
Item { w = 3, v = 4 },
Item { w = 4, v = 5 },
Item { w = 5, v = 8 }
],
cap_5 = solve items 5,
cap_8 = solve items 8
in
(cap_5, cap_8)
Demo: Union-Find
This demo implements core union-find operations for tracking connected components in a small graph. It maintains parent links, finds set representatives, and merges sets with union, then checks connectivity; together these operations demonstrate how incremental edge additions can be answered with near-constant-time component queries.
Related reading: Disjoint-set data structure.
get_parent and set_parent provide indexed access over a fixed-size parent record, while find follows parent pointers recursively until it reaches a representative. union links one representative to another when sets differ, and connected compares representatives; the final block performs a few unions and returns both connectivity checks and representatives to show resulting components.
type UF = UF { p0: i32, p1: i32, p2: i32, p3: i32, p4: i32 };
fn get_parent : UF -> i32 -> i32 = \uf x ->
if x == 0 then uf.p0
else if x == 1 then uf.p1
else if x == 2 then uf.p2
else if x == 3 then uf.p3
else uf.p4;
fn set_parent : UF -> i32 -> i32 -> UF = \uf x p ->
if x == 0 then { uf with { p0 = p } }
else if x == 1 then { uf with { p1 = p } }
else if x == 2 then { uf with { p2 = p } }
else if x == 3 then { uf with { p3 = p } }
else { uf with { p4 = p } };
fn find : UF -> i32 -> i32 = \uf x ->
let px = get_parent uf x in
if px == x then x else find uf px;
fn union : UF -> i32 -> i32 -> UF = \uf a b ->
let
ra = find uf a,
rb = find uf b
in
if ra == rb then uf else set_parent uf rb ra;
fn connected : UF -> i32 -> i32 -> Bool = \uf a b -> find uf a == find uf b;
let
uf0 = UF { p0 = 0, p1 = 1, p2 = 2, p3 = 3, p4 = 4 },
uf1 = union uf0 0 1,
uf2 = union uf1 1 2,
uf3 = union uf2 3 4
in
(
connected uf3 0 2,
connected uf3 0 4,
find uf3 2,
find uf3 4
)
Demo: Prefix Parser + Evaluator
This demo performs recursive-descent parsing over a prefix token stream to build an expression tree, then evaluates that tree. It separates syntax (Tok) from semantics (Expr) and returns both the parsed expression and unconsumed tokens, which is a common parser design for composing larger grammars.
Related reading: Polish notation and Recursive descent parser.
parse_expr is the parser entrypoint and consumes tokens according to constructor shape: numbers produce leaf nodes, while operators recursively parse the required subexpressions. eval then interprets the produced AST, and is_empty checks whether parsing consumed all tokens; the two sample token streams demonstrate both parsing and evaluation in one result tuple.
type Tok = TNum i32 | TPlus | TMul | TNeg;
type Expr = Num i32 | Add Expr Expr | Mul Expr Expr | Neg Expr;
fn parse_expr : List Tok -> (Expr, List Tok) = \toks ->
match toks with {
case [] -> (Num 0, []);
case TNum n::rest -> (Num n, rest);
case TPlus::rest ->
let
(lhs, rest1) = parse_expr rest,
(rhs, rest2) = parse_expr rest1
in
(Add lhs rhs, rest2);
case TMul::rest ->
let
(lhs, rest1) = parse_expr rest,
(rhs, rest2) = parse_expr rest1
in
(Mul lhs rhs, rest2);
case TNeg::rest ->
let (inner, rest1) = parse_expr rest in
(Neg inner, rest1);
};
fn eval : Expr -> i32 = \expr ->
match expr with {
case Num n -> n;
case Add a b -> eval a + eval b;
case Mul a b -> eval a * eval b;
case Neg x -> 0 - eval x;
};
fn is_empty<a> : List a -> Bool = \xs ->
match xs with {
case [] -> true;
case _::_ -> false;
};
let
toks1 = [TPlus, TNum 2, TMul, TNum 3, TNum 4],
toks2 = [TPlus, TNeg, TNum 3, TNum 10],
(ast1, rest1) = parse_expr toks1,
(ast2, rest2) = parse_expr toks2
in
(eval ast1, is_empty rest1, eval ast2, is_empty rest2)
Demo: Topological Sort (Kahn Style)
This demo computes a topological ordering of a directed acyclic graph using a Kahn-style process: repeatedly select nodes with zero in-degree, output them, and remove their outgoing edges. It demonstrates dependency resolution in graph form and returns an empty list when edges remain but no valid next node exists.
Related reading: Topological sorting.
The helpers break the algorithm into pure list operations: in_degree counts incoming edges, remove_outgoing deletes edges from a chosen node, and enqueue_zeros updates the processing queue with newly unlocked nodes. kahn drives the main loop by consuming the queue and accumulating output order, with a final reversal because nodes are prepended during recursion.
type Node = A | B | C | D;
type Edge = Edge Node Node;
fn node_eq : Node -> Node -> Bool = \a b ->
match (a, b) with {
case (A, A) -> true;
case (B, B) -> true;
case (C, C) -> true;
case (D, D) -> true;
case _ -> false;
};
fn contains : List Node -> Node -> Bool = \xs x ->
match xs with {
case [] -> false;
case y::ys -> if node_eq y x then true else contains ys x;
};
fn append : List Node -> List Node -> List Node = \xs ys ->
match xs with {
case [] -> ys;
case h::t -> Cons h (append t ys);
};
fn reverse_go : List Node -> List Node -> List Node = \rest acc ->
match rest with {
case [] -> acc;
case h::t -> reverse_go t (Cons h acc);
};
fn reverse : List Node -> List Node = \xs ->
reverse_go xs [];
fn is_empty<a> : List a -> Bool = \xs ->
match xs with {
case [] -> true;
case _::_ -> false;
};
fn remove_outgoing : List Edge -> Node -> List Edge = \edges n ->
match edges with {
case [] -> [];
case Edge from to::rest ->
if node_eq from n then remove_outgoing rest n
else Cons (Edge from to) (remove_outgoing rest n);
};
fn in_degree : List Edge -> Node -> i32 = \edges n ->
match edges with {
case [] -> 0;
case Edge from to::rest ->
let tail = in_degree rest n in
if node_eq to n then 1 + tail else tail;
};
fn push_unique : List Node -> Node -> List Node = \queue n ->
if contains queue n then queue else append queue [n];
fn enqueue_zeros : List Node -> List Node -> List Node -> List Edge -> List Node = \nodes queue seen edges ->
match nodes with {
case [] -> queue;
case n::rest ->
let queue1 =
if contains seen n then
queue
else if in_degree edges n == 0 then
push_unique queue n
else
queue
in
enqueue_zeros rest queue1 seen edges;
};
fn kahn : List Node -> List Node -> List Node -> List Edge -> List Node -> List Node = \queue seen order edges nodes ->
match queue with {
case [] ->
if is_empty edges then
reverse order
else
[];
case n::rest ->
let
edges1 = remove_outgoing edges n,
seen1 = Cons n seen,
queue1 = enqueue_zeros nodes rest seen1 edges1
in
kahn queue1 seen1 (Cons n order) edges1 nodes;
};
let
nodes = [A, B, C, D],
edges = [Edge A B, Edge A C, Edge B D, Edge C D],
initial = enqueue_zeros nodes [] [] edges
in
kahn initial [] [] edges nodes
Demo: N-Queens Backtracking
This demo counts valid placements of queens on an N x N chessboard using backtracking with pruning. It places one queen per row, rejects positions that share a column or diagonal with prior queens, and recursively explores only safe continuations, which makes it a compact example of constraint search.
Related reading: N-queens problem.
is_safe checks a candidate column against previously placed queens, carrying a diagonal distance counter so both diagonal directions can be tested with abs_i32. try_cols iterates candidate columns within a row and accumulates solution counts, while count_from advances to the next row after each safe placement; solve just seeds the recursion with row 0 and an empty placement list.
fn abs_i32 : i32 -> i32 = \x ->
if x < 0 then 0 - x else x;
fn is_safe : i32 -> List i32 -> i32 -> Bool = \col placed dist ->
match placed with {
case [] -> true;
case c::rest ->
if col == c then
false
else if abs_i32 (col - c) == dist then
false
else
is_safe col rest (dist + 1);
};
fn count_from : i32 -> i32 -> List i32 -> i32 = \row n placed ->
if row == n then
1
else
try_cols row n placed 0;
fn try_cols : i32 -> i32 -> List i32 -> i32 -> i32 = \row n placed col ->
if col == n then
0
else
let rest = try_cols row n placed (col + 1) in
if is_safe col placed 1 then
count_from (row + 1) n (Cons col placed) + rest
else
rest;
fn solve : i32 -> i32 = \n ->
count_from 0 n [];
(solve 4, solve 5)
Built-in types & functions
This page is auto-generated from the prelude source. Run
cargo run -p rex-cli --bin gen_prelude_docsto refresh it.
Built-in Types
| Type | Description |
|---|---|
Bool | Boolean truth value. |
Char | One Unicode scalar value, matching Rust’s char. |
DateTime | UTC timestamp value. |
Dict a | Immutable mapping from string keys to values of one type. |
Hash | BLAKE3 hash value. |
List a | Immutable ordered sequence. Constructors: List.Empty, List.Cons. |
Option a | Optional value (Some or None). Constructors: Option.Some, Option.None. |
Ordering | Three-way comparison result (Less, Equal, or Greater). Constructors: Ordering.Less, Ordering.Equal, Ordering.Greater. |
Result a b | Result value (Ok or Err) for success/failure flows. Constructors: Result.Err, Result.Ok. |
String | UTF-8 string value. |
UUID | UUID value. |
f32 | 32-bit floating-point number. |
f64 | 64-bit floating-point number. |
i16 | 16-bit signed integer. |
i32 | 32-bit signed integer. |
i64 | 64-bit signed integer. |
i8 | 8-bit signed integer. |
u16 | 16-bit unsigned integer. |
u32 | 32-bit unsigned integer. |
u64 | 64-bit unsigned integer. |
u8 | 8-bit unsigned integer. |
Reading Function Entries
Rex functions are curried, so f first second can be partially applied as f first. The Call paragraph gives every parameter a stable, descriptive name. The Type paragraph gives the inferred Rex type; type variables begin with an apostrophe. For overloaded functions, Available for lists the built-in types and type constructors that provide the operation.
Boolean Operations
Boolean operators combine two Bool values.
&&
Call: left && right
Type: Bool -> Bool -> Bool
Returns true only when both left and right are true.
||
Call: left || right
Type: Bool -> Bool -> Bool
Returns true when either left or right is true.
Comparison Operations
Equality is available for all listed equality-comparable types. Ordering operations are available for numbers, characters, and strings.
==
Call: left == right
Type: 'a -> 'a -> Bool
Available for: u8, u16, u32, u64, i8, i16, i32, i64, f32, f64, Bool, Char, String, UUID, Hash, DateTime, List 'a, Option 'a, Result 'a 'e
Returns true when left and right are equal. Lists, options, and results require their contained types to support equality.
!=
Call: left != right
Type: 'a -> 'a -> Bool
Available for: u8, u16, u32, u64, i8, i16, i32, i64, f32, f64, Bool, Char, String, UUID, Hash, DateTime, List 'a, Option 'a, Result 'a 'e
Returns true when left and right are not equal. Lists, options, and results require their contained types to support equality.
<
Call: left < right
Type: 'a -> 'a -> Bool
Available for: u8, u16, u32, u64, i8, i16, i32, i64, f32, f64, Char, String
Returns true when left is less than right.
<=
Call: left <= right
Type: 'a -> 'a -> Bool
Available for: u8, u16, u32, u64, i8, i16, i32, i64, f32, f64, Char, String
Returns true when left is less than or equal to right.
>
Call: left > right
Type: 'a -> 'a -> Bool
Available for: u8, u16, u32, u64, i8, i16, i32, i64, f32, f64, Char, String
Returns true when left is greater than right.
>=
Call: left >= right
Type: 'a -> 'a -> Bool
Available for: u8, u16, u32, u64, i8, i16, i32, i64, f32, f64, Char, String
Returns true when left is greater than or equal to right.
cmp
Call: cmp left right
Type: 'a -> 'a -> Ordering
Available for: u8, u16, u32, u64, i8, i16, i32, i64, f32, f64, Char, String
Returns Less, Equal, or Greater according to the ordering of left relative to right.
Ordering Values
cmp returns one of these Ordering values. The bare names are convenient aliases for the type-qualified constructors.
Ordering.Less
Call: Ordering.Less
Type: Ordering
The type-qualified Ordering value returned when the left value is less than the right value.
Ordering.Equal
Call: Ordering.Equal
Type: Ordering
The type-qualified Ordering value returned when two values are equal.
Ordering.Greater
Call: Ordering.Greater
Type: Ordering
The type-qualified Ordering value returned when the left value is greater than the right value.
Less
Call: Less
Type: Ordering
The Ordering value returned when the left value is less than the right value.
Equal
Call: Equal
Type: Ordering
The Ordering value returned when two values are equal.
Greater
Call: Greater
Type: Ordering
The Ordering value returned when the left value is greater than the right value.
Arithmetic and Aggregation
Arithmetic operators work on the numeric types listed for each operation. Some operations are also overloaded for lists or strings where noted.
zero
Call: zero
Type: 'a
Available for: List 'a, String, u8, u16, u32, u64, i8, i16, i32, i64, f32, f64
Returns the additive identity for the inferred result type.
one
Call: one
Type: 'a
Available for: u8, u16, u32, u64, i8, i16, i32, i64, f32, f64
Returns the multiplicative identity for the inferred result type.
+
Call: left + right
Type: 'a -> 'a -> 'a
Available for: List 'a, String, u8, u16, u32, u64, i8, i16, i32, i64, f32, f64
Adds left and right; for lists and strings, concatenates left with right.
-
Call: left - right
Type: 'a -> 'a -> 'a
Available for: u8, u16, u32, u64, i8, i16, i32, i64, f32, f64
Subtracts right from left.
*
Call: left * right
Type: 'a -> 'a -> 'a
Available for: u8, u16, u32, u64, i8, i16, i32, i64, f32, f64
Multiplies left by right.
/
Call: left / right
Type: 'a -> 'a -> 'a
Available for: u8, u16, u32, u64, i8, i16, i32, i64, f32, f64
Divides left by right.
%
Call: left % right
Type: 'a -> 'a -> 'a
Available for: u8, u16, u32, u64, i8, i16, i32, i64
Returns the remainder after dividing left by right.
negate
Call: negate value
Type: 'a -> 'a
Available for: i8, i16, i32, i64, f32, f64
Returns the additive inverse of value.
sum
Call: sum values
Type: 'f 'a -> 'a
Available for: List 'a and Option 'a, where 'a is a numeric type, String, or another List type
Combines all elements in values using addition, beginning with that element type’s additive identity.
mean
Call: mean values
Type: 'f 'a -> 'a
Available for: List f32, List f64, Option f32, and Option f64
Returns the arithmetic mean of values; raises an error when values is empty.
min
Call: min values
Type: 'f 'a -> 'a
Available for: List 'a and Option 'a, where 'a is a numeric type, Char, or String
Returns the least element in values according to its ordering; raises an error when values is empty.
max
Call: max values
Type: 'f 'a -> 'a
Available for: List 'a and Option 'a, where 'a is a numeric type, Char, or String
Returns the greatest element in values according to its ordering; raises an error when values is empty.
General Value Functions
Functions for constructing defaults, parsing strings, and rendering values.
default
Call: default
Type: 'a
Available for: Bool, u8, u16, u32, u64, i8, i16, i32, i64, f32, f64, Char, String, List 'a, Option 'a, Result 'a 'e
Returns the canonical default value for the inferred result type. For Result a e, the success type a must also have a default.
parse
Call: parse input
Type: String -> Option 'a
Available for: Bool, u8, u16, u32, u64, i8, i16, i32, i64, f32, f64, Char, UUID, Hash, DateTime
Attempts to convert input to the result type selected by context, returning Some value on success or None for malformed or out-of-range input.
show
Call: show value
Type: 'a -> String
Available for: Bool, u8, u16, u32, u64, i8, i16, i32, i64, f32, f64, Char, String, UUID, Hash, DateTime, List 'a, Option 'a, Result 'a 'e
Renders value as a human-readable string. Lists, options, and results require their contained types to be renderable.
Collection and Container Functions
Generic operations shared by lists, options, results, dictionaries, or strings. Check the availability list on each function.
length
Call: length value
Type: 'a -> u64
Available for: List 'a, Dict 'a, String
Returns the number of list elements, dictionary entries, or string Unicode scalar values in value.
map
Call: map transform container
Type: ('a -> 'b) -> 'f 'a -> 'f 'b
Available for: List, Option, Result 'e, Dict
Applies transform to every value in container while preserving its structure.
filter
Call: filter predicate container
Type: ('a -> Bool) -> 'f 'a -> 'f 'a
Available for: List, Option, Dict
Keeps each value in container for which predicate returns true.
filter_map
Call: filter_map transform container
Type: ('a -> Option 'b) -> 'f 'a -> 'f 'b
Available for: List, Option, Dict
Applies transform to each value in container and drops every result that is None.
foldl
Call: foldl step initial container
Type: ('b -> 'a -> 'b) -> 'b -> 't 'a -> 'b
Available for: List, Option
Strictly reduces container from left to right by applying step to the accumulator and each value, beginning with initial.
foldr
Call: foldr step initial container
Type: ('a -> 'b -> 'b) -> 'b -> 't 'a -> 'b
Available for: List, Option
Reduces container from right to left by applying step to each value and the accumulator, beginning with initial.
fold
Call: fold step initial container
Type: ('b -> 'a -> 'b) -> 'b -> 't 'a -> 'b
Available for: List, Option
Reduces container from left to right by applying step to the accumulator and each value, beginning with initial.
pure
Call: pure value
Type: 'a -> 'f 'a
Available for: List, Option, Result 'e
Wraps value in the inferred container type.
ap
Call: ap functions values
Type: 'f ('a -> 'b) -> 'f 'a -> 'f 'b
Available for: List, Option, Result 'e
Applies the wrapped functions in functions to the wrapped values in values.
bind
Call: bind transform container
Type: ('a -> 'm 'b) -> 'm 'a -> 'm 'b
Available for: List, Option, Result 'e
Applies transform to each successful value in container and flattens the resulting container layer.
or_else
Call: or_else fallback value
Type: ('f 'a -> 'f 'a) -> 'f 'a -> 'f 'a
Available for: List, Option, Result 'e
Returns value when it is non-empty, present, or successful; otherwise applies fallback to value.
List Functions
Construct, index, slice, and combine List values.
List.Empty
Call: List.Empty
Type: List 'a
The type-qualified empty list constructor.
List.Cons
Call: List.Cons head tail
Type: 'a -> List 'a -> List 'a
Constructs a non-empty list whose first element is head and whose remaining elements are tail.
Empty
Call: Empty
Type: List 'a
The empty list constructor.
Cons
Call: Cons head tail
Type: 'a -> List 'a -> List 'a
Constructs a non-empty list whose first element is head and whose remaining elements are tail.
list_get
Call: list_get index list
Type: u64 -> List 'a -> Option 'a
Returns the element at zero-based index, or None when index is out of bounds.
list_slice
Call: list_slice start end list
Type: u64 -> u64 -> List 'a -> Option (List 'a)
Returns the half-open range start..end, or None for invalid bounds.
list_reverse
Call: list_reverse list
Type: List 'a -> List 'a
Returns the elements of list in reverse order.
list_concat
Call: list_concat lists
Type: List (List 'a) -> List 'a
Concatenates the nested lists into one list while preserving their order.
list_repeat
Call: list_repeat count value
Type: u64 -> 'a -> List 'a
Returns a list containing count copies of value.
list_any
Call: list_any predicate list
Type: ('a -> Bool) -> List 'a -> Bool
Returns whether predicate is true for any element, stopping at the first true result.
list_all
Call: list_all predicate list
Type: ('a -> Bool) -> List 'a -> Bool
Returns whether predicate is true for every element, stopping at the first false result.
list_find
Call: list_find predicate list
Type: ('a -> Bool) -> List 'a -> Option 'a
Returns the first element for which predicate returns true, or None when no element matches.
list_find_index
Call: list_find_index predicate list
Type: ('a -> Bool) -> List 'a -> Option u64
Returns the zero-based index of the first matching element, or None when no element matches.
list_count
Call: list_count predicate list
Type: ('a -> Bool) -> List 'a -> u64
Returns the number of elements for which predicate returns true.
list_partition
Call: list_partition predicate list
Type: ('a -> Bool) -> List 'a -> (List 'a, List 'a)
Returns matching and non-matching elements as a pair of lists, preserving their relative order.
take
Call: take count list
Type: u64 -> 'f 'a -> 'f 'a
Available for: List
Returns at most the first count elements from list.
skip
Call: skip count list
Type: u64 -> 'f 'a -> 'f 'a
Available for: List
Drops the first count elements from list; when count exceeds its length, returns an empty list.
first
Call: first count list
Type: i32 -> List 'a -> List 'a
Returns the first count elements of list; raises an error when count is out of range.
last
Call: last count list
Type: i32 -> List 'a -> List 'a
Returns the last count elements of list; raises an error when count is out of range.
slice
Call: slice start end list
Type: i32 -> i32 -> List 'a -> List 'a
Returns elements in the half-open range start..end from list; raises an error for out-of-range bounds or when end < start.
zip
Call: zip left right
Type: 'f 'a -> 'f 'b -> 'f ('a, 'b)
Available for: List
Pairs elements from left and right by position, stopping when either list ends.
unzip
Call: unzip pairs
Type: 'f ('a, 'b) -> ('f 'a, 'f 'b)
Available for: List
Splits the list of pairs into a pair of lists containing their first and second components.
Dict Functions
Dictionary-specific operations. Keys are strings, dictionaries are immutable, and operations that modify a dictionary return a new value.
dict_empty
Call: dict_empty
Type: Dict 'a
Constructs an empty dictionary.
dict_singleton
Call: dict_singleton key value
Type: String -> 'a -> Dict 'a
Constructs a dictionary containing the single association from key to value.
dict_get
Call: dict_get key dictionary
Type: String -> Dict 'a -> Option 'a
Looks up key in dictionary, returning Some value when present or None when absent.
dict_has
Call: dict_has key dictionary
Type: String -> Dict 'a -> Bool
Returns whether dictionary contains key.
dict_insert
Call: dict_insert key value dictionary
Type: String -> 'a -> Dict 'a -> Dict 'a
Returns dictionary with key associated with value, replacing the previous value when the key exists.
dict_remove
Call: dict_remove key dictionary
Type: String -> Dict 'a -> Dict 'a
Returns dictionary without key.
dict_update
Call: dict_update key update dictionary
Type: String -> (Option 'a -> Option 'a) -> Dict 'a -> Dict 'a
Calls update with the optional current value for key; returning Some value inserts or replaces the entry, while returning None removes it.
dict_is_empty
Call: dict_is_empty dictionary
Type: Dict 'a -> Bool
Returns whether dictionary has no entries.
dict_keys
Call: dict_keys dictionary
Type: Dict 'a -> List String
Returns the keys from dictionary in lexicographic order.
dict_values
Call: dict_values dictionary
Type: Dict 'a -> List 'a
Returns the values from dictionary in lexicographic key order.
dict_entries
Call: dict_entries dictionary
Type: Dict 'a -> List (String, 'a)
Returns the key/value tuples from dictionary in lexicographic key order.
dict_from_entries
Call: dict_from_entries entries
Type: List (String, 'a) -> Dict 'a
Constructs a dictionary from entries; when a key occurs more than once, its later value wins.
dict_map
Call: dict_map transform dictionary
Type: ((String, 'a) -> (String, 'b)) -> Dict 'a -> Dict 'b
Applies transform to each (key, value) tuple in dictionary; later collisions in input-key order win.
dict_filter
Call: dict_filter predicate dictionary
Type: ((String, 'a) -> Bool) -> Dict 'a -> Dict 'a
Keeps the entries from dictionary for which predicate returns true; predicate receives each (key, value) tuple.
String Functions
String indexing and positions count Unicode scalar values, not UTF-8 bytes. Functions with selector or modifier arguments place those arguments before the input string.
string_get
Call: string_get index input
Type: u64 -> String -> Option Char
Returns the character at zero-based Unicode scalar index in input, or None when index is out of bounds.
string_slice
Call: string_slice start end input
Type: u64 -> u64 -> String -> Option String
Returns the half-open Unicode scalar range start..end from input, or None for invalid bounds.
string_contains
Call: string_contains needle haystack
Type: String -> String -> Bool
Returns whether haystack contains needle as a substring.
string_starts_with
Call: string_starts_with prefix input
Type: String -> String -> Bool
Returns whether input starts with prefix.
string_ends_with
Call: string_ends_with suffix input
Type: String -> String -> Bool
Returns whether input ends with suffix.
string_find
Call: string_find needle haystack
Type: String -> String -> Option u64
Finds needle in haystack, returning its first Unicode scalar index or None.
string_split
Call: string_split separator input
Type: String -> String -> List String
Splits input at each non-overlapping occurrence of separator.
string_join
Call: string_join separator parts
Type: String -> List String -> String
Joins parts, inserting separator between adjacent strings.
string_replace
Call: string_replace needle replacement input
Type: String -> String -> String -> String
Returns input with every non-overlapping occurrence of needle replaced by replacement.
string_trim
Call: string_trim input
Type: String -> String
Removes Unicode whitespace from both ends of input.
string_trim_start
Call: string_trim_start input
Type: String -> String
Removes Unicode whitespace from the start of input.
string_trim_end
Call: string_trim_end input
Type: String -> String
Removes Unicode whitespace from the end of input.
string_to_lower
Call: string_to_lower input
Type: String -> String
Converts input using the Unicode lowercase mapping.
string_to_upper
Call: string_to_upper input
Type: String -> String
Converts input using the Unicode uppercase mapping.
string_to_chars
Call: string_to_chars input
Type: String -> List Char
Returns the Unicode scalar values in input as a character list.
chars_to_string
Call: chars_to_string chars
Type: List Char -> String
Concatenates the Unicode scalar values in chars into a string.
string_to_utf8
Call: string_to_utf8 input
Type: String -> List u8
Encodes input as its UTF-8 byte sequence.
utf8_to_string
Call: utf8_to_string bytes
Type: List u8 -> Option String
Decodes bytes as UTF-8, returning None when the byte sequence is invalid.
Option and Result Functions
Construct, inspect, and extract optional values and success-or-error results. The bare constructor names are convenient aliases for the type-qualified forms.
Option.None
Call: Option.None
Type: Option 't
The type-qualified empty Option constructor.
Option.Some
Call: Option.Some value
Type: 't -> Option 't
Constructs a present Option containing value using its type-qualified name.
None
Call: None
Type: Option 't
The empty Option constructor.
Some
Call: Some value
Type: 't -> Option 't
Constructs a present Option containing value.
is_none
Call: is_none option
Type: Option 'a -> Bool
Returns whether option is None.
is_some
Call: is_some option
Type: Option 'a -> Bool
Returns whether option is Some.
Result.Err
Call: Result.Err error
Type: 'e -> Result 't 'e
Constructs a failed Result containing error using its type-qualified name.
Result.Ok
Call: Result.Ok value
Type: 't -> Result 't 'e
Constructs a successful Result containing value using its type-qualified name.
Err
Call: Err error
Type: 'e -> Result 't 'e
Constructs a failed Result containing error.
Ok
Call: Ok value
Type: 't -> Result 't 'e
Constructs a successful Result containing value.
is_err
Call: is_err result
Type: Result 't 'e -> Bool
Returns whether result is Err.
is_ok
Call: is_ok result
Type: Result 't 'e -> Bool
Returns whether result is Ok.
unwrap
Call: unwrap value
Types: Option 'a -> 'a; Result 't 'e -> 't
Extracts the value from Some or Ok; raises an error when value is None or Err.
Rex Language Guide
Rex is a small, strongly-typed functional DSL with:
- Hindley–Milner type inference (let-polymorphism)
- algebraic data types (ADTs), including record-carrying constructors
- Haskell-style type classes (including higher-kinded classes like
Functor)
This guide is meant for users and embedders. For locked/production-facing semantics and edge cases, see SPEC.md.
Source Forms
A Rex source is parsed as a compilation unit containing:
- zero or more declarations (
type,class,instance,fn,import) - optionally followed by a single expression
Sources with a final expression are snippets or program entry points. Declaration-only sources are modules.
Example snippet:
fn inc : i32 -> i32 = \x -> x + 1;
let
xs = [1, 2, 3]
in
map inc xs
Program Entry Points
When a source is run as a program, Rex uses one entry point:
- If the source defines
fn main,mainis the entry point. The same source must not also contain a final expression. - If the source does not define
main, the final expression is treated as an implicit zero-argument entry point. - If there is no
mainand no final expression, running the source as a program is an error.
The CLI passes arguments to main from a JSON file supplied with --inputs.
The JSON file is a top-level object whose fields match the parameter names:
fn main scale: i32 -> offset: i32 -> i32 =
scale + offset;
{
"scale": 3,
"offset": 4
}
The values are converted to Rex values using the parameter types. Runnable files
without main use their final expression as the entry point, so their input
shape is {}.
Modules and Imports
Rex modules are named entries in an abstract module namespace. Imports are semicolon-terminated top-level declarations. Source-backed modules are declaration-only: they do not have a top-level expression result. To evaluate an expression, run a source as a program entry point.
In embedded applications, the host decides how module names are resolved. An importer can map a module name to Rex source, a prebuilt compilation unit, a database row, hard-coded strings, or a Rust-backed module. The core language does not require a module to be a file.
Supported forms:
import foo.bar as Bar;
import foo.bar (*);
import foo.bar (x, y as z);
import ./foo/bar (x);
import ../../foo/bar as FB;
Semantics:
import foo.bar as Barimports a module alias; use qualified access (Bar.name).- Alias-qualified lookup is namespace-aware:
- expression/pattern positions use exported values and constructors (
Bar.value). - type positions use exported types (
Bar.Type). - class-constraint positions use exported classes (
Bar.Class).
- expression/pattern positions use exported values and constructors (
import foo.bar (*)imports every exported name into local unqualified scope.import foo.bar (x, y as z)imports selected exported names;yis bound locally asz.- Unqualified imports are context-sensitive:
- expression/pattern positions use the imported value facet
- type positions use the imported type facet
- class-constraint positions use the imported class facet
- Importing a name does not invent missing facets. For example, importing a type name does not make it usable as a value unless the module also exports a value with that same spelling.
- A single exported name may carry multiple facets at once. ADTs commonly do this:
Boxedcan be both a type name and a constructor value. - Module alias imports and clause imports are mutually exclusive in one import declaration.
- Only
pubnames are importable. - If two imports introduce the same unqualified name (including via
(*)), resolution fails with a module error. - Importing a name that conflicts with a local top-level declaration is a module error.
- Lexical bindings (
let, lambda params, pattern bindings) can shadow imported names. - For binder forms with annotations, the annotation is resolved before the new binder name enters expression scope.
Examples:
import sample (Boxed);
let id: Boxed -> Boxed = \x -> x in
id (Boxed 1)
Here Boxed is imported once, but it can be used in both type position and expression position.
import sample (Status, Ready);
let id: Status -> Status = \x -> x in
id Ready
This only works if sample exports Status as a type and Ready as a value. Importing Status
alone does not make Status available as an expression unless the module also exports a value
named Status.
Path resolution:
- Module IDs are qualified names such as
foo.bar,std.prelude, orffmpeg.formats.av1. - Each module-name segment must start with a letter or
_, followed by letters, digits, or_. foo.barrequests module IDfoo.bar; it does not inherently meanfoo/bar.rex.- The CLI installs a filesystem importer that maps module IDs to
.rexfiles. Other embedders can resolve the same module ID from any backing store. - Relative-looking import spellings such as
./foo/barand../../foo/barare parsed into module path segments and resolved by importer policy using the importing module as context. - Imports name modules only; URL imports and content-hash suffixes are not part of the syntax.
Lexical Structure
Whitespace and Comments
- Whitespace, including newlines, is generally insignificant and indentation has no syntactic
meaning. Top-level
type,fn, anddeclare fndeclarations, marker classes/instances, and class/instance items use explicit semicolon terminators. - Comments use
// ...for line comments and/* ... */for block comments. They are stripped before parsing. - Nested block comments are not supported in current Rex builds.
Identifiers and Operators
- Identifiers start with a letter or
_, followed by letters/digits/underscores. - Operators are non-alphanumeric symbol sequences (
+,*,==,<, …). - Operators can be used as values by parenthesizing:
(+),(==),(<).
Lambdas
The lambda syntax is \x -> expr. Rex only accepts the ASCII spellings \ and ->.
Expressions
Literals
true,false- integers and floats (integer literals are overloaded over
Integraland default toi32when ambiguous) - strings:
"hello" - UUID and datetime literals, when enabled by the parser
Examples:
( (4 is u8)
, (4 is u64)
, (4 is i16)
, (-3 is i16)
)
Negative literals only specialize to signed types. For example, (-3 is u8) is a type error.
Rex can implicitly widen primitive integers when the target type is already known and the conversion
is lossless. For example, an i8 value can be passed to an i32 parameter, but Rex will not guess a
common type for (1 is i8) + (2 is i32).
Function Application
Application is left-associative: f x y parses as (f x) y.
let add = \x y -> x + y in add 1 2
Let-In
Let binds one or more definitions and then evaluates a body:
let
x = 1 + 2,
y = 3
in
x * y
Let bindings are polymorphic (HM “let-generalization”):
let id = \x -> x in (id 1, id true, id "hi")
Integer-literal bindings are a special case: unannotated let x = 4 is kept monomorphic so use
sites can specialize it through context.
let
x = 4,
f: u8 -> u8 = \y -> y
in
f x
Recursive Let (let rec)
Use let rec for self-recursive and mutually-recursive bindings.
let rec
even = \n -> if n == 0 then true else odd (n - 1),
odd = \n -> if n == 0 then false else even (n - 1)
in
(even 10, odd 11)
Notes:
- Bindings in
let recare separated by commas. - A binding whose right-hand side is a lambda is treated as a recursive function binding.
- Other bindings are initialized like sequential
letvalues and can only reference earlier bindings in the same group. - Function bodies can reference any binding in the same group.
- A value binding is rejected if it calls an earlier function whose body can reach a binding that has not been initialized yet.
If-Then-Else
if 1 < 2 then "ok" else "no"
Tuples, Lists, Dictionaries
(1, "hi", true)
[1, 2, 3]
{ a = 1, b = 2 }
Notes:
- Lists are implemented as a
List aADT (Empty/Cons) in the prelude. - Cons expressions use
::(for examplex::xs), equivalent toCons x xs. Consis used with normal constructor-call syntax (Cons head tail), while::is infix sugar.- Dictionary literals
{ k = v, ... }build record/dict values. They become records when used as the payload of an ADT record constructor, or when their type is inferred/annotated as a record.
:: is right-associative, so 1::2::[] means 1::(2::[]).
let
xs = 1::2::3::[]
in
xs
Pattern Matching
match performs structural matching. The scrutinee is followed by with, then one or more
semicolon-terminated case arms inside a braced arm block:
match xs with {
case Empty -> 0;
case Cons h t -> h;
}
Patterns include:
- wildcards:
_ - variables:
x - constructors:
Ok x,Cons h t,Pair a b - qualified constructors via module alias:
Sample.Right x - list patterns:
[],[x],[x, y] - cons patterns:
h::t(equivalent toCons h t) - dict key presence:
{foo, bar}(keys are identifiers) - record patterns on record-carrying constructors:
Bar {x, y}
match [1, 2, 3] with {
case h::t -> h;
case [] -> 0;
}
Rex checks ADT matches for exhaustiveness and reports missing constructors.
Types
Primitive Types
Common built-in types include:
Booli32(default integer-literal fallback type)f32(float literal type)Char(one Unicode scalar value, written with single quotes)StringUUIDHashDateTime
Function Types
Functions are right-associative: a -> b -> c means a -> (b -> c).
Tuples, Lists, Dicts
- Tuple type:
(a, b, c) - List type:
List a(prelude) - Dict type:
Dict a(prelude; keys are strings and all values have typea) - Promise type:
Promise a(built-in unary type constructor; embedders may attach their own operations)
ADTs
Define an ADT with type. Each top-level type declaration is terminated by a semicolon:
type Maybe a = Just a | Nothing;
Constructors are values (functions) in the prelude environment:
Just 1
Nothing
Record-Carrying Constructors
ADT variants can carry a record payload:
type User = User { name: String, age: i32 };
let u: User = User { name = "Ada", age = 36 } in u
Named Record Aliases
Use type Name = { ... }; to give a structural record type a reusable name without introducing an
ADT constructor:
type User = { name: String, age: i32 };
let u: User = { name = "Ada", age = 36 } in u.name
Aliases may have type parameters and remain transparent:
type Tagged a = { tag: String, value: a };
let item: Tagged i32 = { tag = "answer", value = 42 } in item.value
Type Annotations
Annotate let bindings, lambda parameters, and function declarations:
let x: i32 = 1 in x
Annotations can mention ADTs and prelude types:
let xs: List i32 = [1, 2, 3] in xs
They can also use module-qualified type names:
import dep as D;
fn id x: D.Boxed -> D.Boxed = x;
Records: Projection and Update
Rex supports:
- projection:
x.field - record update:
{ base with { field = expr } }
Projection and update are valid when the field is definitely available on the base:
- on plain record types
{ field: Ty, ... } - on named record aliases, which expand to plain record types
- on single-variant ADTs whose payload is a record
- on multi-variant ADTs only after the constructor has been proven (typically by
match)
Example (multi-variant refinement via match):
type Sum = A { x: i32 } | B { x: i32 };
let s: Sum = A { x = 1 } in
match s with {
case A {x} -> { s with { x = x + 1 } };
case B {x} -> { s with { x = x + 2 } };
}
Declarations
Functions (fn)
Top-level functions are declared with an explicit type signature and a value (typically a lambda).
Each top-level fn declaration is terminated by a semicolon:
fn add x: i32 -> y: i32 -> i32 = x + y;
Top-level fn declarations are mutually recursive, so they can refer to each other in the same
module:
fn even n: i32 -> Bool =
if n == 0 then true else odd (n - 1);
fn odd n: i32 -> Bool =
if n == 0 then false else even (n - 1);
even 10
Type Classes (class)
Type classes declare overloaded operations. Method signatures live in the class:
class Size a where {
size : a -> i32;
}
Classes with no methods are terminated with a semicolon:
class Marker a;
Methods can be operators (use parentheses to refer to them as values if needed):
class Eq a where {
== : a -> a -> Bool;
}
Superclasses use <= (read “requires”):
class Ord a <= Eq a where {
< : a -> a -> Bool;
}
Instances (instance)
Instances attach method implementations to a concrete head type, optionally with constraints:
class Size a where {
size : a -> i32;
}
instance<t> Size (List t) where {
size = \xs ->
match xs with {
case Empty -> 0;
case Cons _ rest -> 1 + size rest;
};
}
Instances with no method implementations are also terminated with a semicolon:
instance Marker i32;
The class in an instance header may be module-qualified:
import dep as D;
instance D.Pick i32 where {
pick = 7;
}
Instance contexts use <=:
class Show a where {
show : a -> String;
}
instance Show i32 where {
show = \_ -> "<i32>";
}
instance<a> Show (List a) <= Show a where {
show = \xs ->
let
step = \out x ->
if out == "["
then out + show x
else out + ", " + show x,
out = foldl step "[" xs
in
out + "]";
}
Notes:
- Instance heads are non-overlapping per class (overlap is rejected).
- Inside instance method bodies, the instance context is the only source of “given” constraints.
Prelude Type Classes (Selected)
Rex ships a prelude with common abstractions and instances. Highlights:
- numeric hierarchy:
AdditiveMonoid,Semiring,Ring,Field, … Default(default) for common scalar and container typesEq/OrdFunctor/Applicative/MonadforList,Option,ResultFoldable,Filterable,Sequence- list range helpers:
first n xs,last n xs, and half-openslice n m xs - total list access and query helpers such as
list_get,list_slice,list_find, andlist_partition
Example: Functor across different container types:
( map ((*) 2) [1, 2, 3]
, map ((+) 1) (Some 41)
, map ((*) 2) (Ok 21)
)
Example: safe list indexing:
list_get 0 [10, 20, 30]
Defaulting (Ambiguous Types)
Rex supports defaulting for variables constrained by defaultable classes
(for example AdditiveMonoid).
This matters for expressions like zero where no concrete type is otherwise forced.
This defaulting pass is separate from the Default type class method default.
Example:
zero
With no other constraints, zero defaults to a concrete candidate type. See
SPEC.md for the exact algorithm and candidate order.
Rex Spec (Locked Semantics)
This document records the intended, production-facing semantics of the current Rex implementation. When behavior changes, this file and the corresponding regression tests should be updated together.
Regression tests live in:
rex/tests/spec_semantics.rsrex/tests/record_update.rsrex/tests/typeclasses_system.rsrex/tests/negative.rs
Notation
Γ ⊢ e : τmeans “under type environmentΓ, expressionehas typeτ”.C τmeans a typeclass predicate (constraint) for classCat typeτ.- “Ground” means “contains no free type variables” (
ftv(τ) = ∅). - Rex’s multi-parameter classes are represented internally by packing the parameters into tuples:
- unary
C aisPredicate { class: C, typ: a } - binary
C t aisPredicate { class: C, typ: (t, a) } - etc.
- unary
Lexical Comments
Rex comments are lexical trivia and are removed before parsing:
//starts a line comment that runs to the next newline or end of file./* ... */starts a block comment. Block comments may span lines.- Nested block comments are not supported.
- The legacy
{- ... -}spelling is ordinary syntax, not a comment.
Character and String Literals
Character and string literals are decoded during lexing.
- Double-quoted (
"...") literals produceStringvalues. - Single-quoted (
'...') literals produceCharvalues and must decode to exactly one Unicode scalar value.Charhas the same value domain as Rust’schar: surrogate code points and values aboveU+10FFFFare rejected, while every valid Unicode scalar value is accepted. - C-style simple escapes are supported:
\a,\b,\f,\n,\r,\t,\v,\\,\",\', and\?. - Octal escapes use one to three octal digits (
\0through\777). - Hex escapes use
\xfollowed by one or more hexadecimal digits. - Unicode escapes use
\ufollowed by exactly four hexadecimal digits, or\Ufollowed by exactly eight hexadecimal digits. - Backslash-newline is a line continuation and produces no character.
- Unsupported or malformed escape sequences are lexical errors.
- At JSON boundaries,
Charis represented by a JSON string containing exactly one Unicode scalar value.
String Operations
String operations put selectors and modifiers first and the primary input value last, matching the
rest of the collection API. For example, string_contains needle haystack,
string_split separator input, and string_replace needle replacement input can be partially
applied to form reusable predicates or transformations.
- String positions are zero-based Unicode scalar indices, never UTF-8 byte offsets.
string_get index inputreturns the scalar atindex, whilestring_slice start end inputuses a half-openstart..endrange.string_getreturnsNonefor an out-of-bounds index.string_slicereturnsNoneunless0 <= start <= end <= length input; an empty in-bounds range returnsSome "". string_contains needle haystack,string_starts_with prefix input, andstring_ends_with suffix inputperform literal substring tests.string_find needle haystackreturns the first matching Unicode scalar index orNone; an empty needle is found at index zero.string_split separator inputsplits at non-overlapping literal matches and preserves empty segments, including trailing segments. An empty separator produces an empty segment at each end and one string for every Unicode scalar in between.string_join separator partsinserts the separator only between adjacent elements.string_replace needle replacement inputreplaces every non-overlapping literal match. An empty needle inserts the replacement at every Unicode scalar boundary, including both ends.string_trim,string_trim_start, andstring_trim_endremove Unicode whitespace as classified by Rust’schar::is_whitespace.string_to_lowerandstring_to_upperapply Unicode case mappings and may change the number of scalar values.string_to_charsandchars_to_stringconvert losslessly between strings and lists of Unicode scalar values.string_to_utf8returns the UTF-8 bytes of a string.utf8_to_stringreturnsSomefor valid UTF-8 andNonefor an invalid byte sequence.
Length
length returns a u64 and is implemented for lists, dictionaries, and strings:
- Lists return their number of elements.
- Dictionaries return their number of entries.
- Strings return their number of Unicode scalar values, not their UTF-8 byte length or number of
user-perceived grapheme clusters. For example,
length "h\u00e9\U0001F600" == 3andlength "e\u0301" == 2.
Option does not implement Length.
Dictionaries
Dict a is an immutable mapping from String keys to values of one uniform type a. Runtime
dictionary and record field maps store keys as strings; compiler identifiers and statically known
record field names remain symbols only inside the compiler.
Dictionary iteration order is ascending lexicographic string order. This order is observable in
dict_keys, dict_values, dict_entries, and the collision behavior of dict_map.
The core operations have these semantics:
dict_getreturnsSome valuefor a present key andNonefor an absent key.dict_hastests key presence.dict_insert,dict_remove, anddict_updatereturn new dictionaries without changing their inputs.dict_update key fcallsfwith the current optional value;Some valuein the result inserts or replaces the key, whileNoneremoves it.dict_keys,dict_values, anddict_entriesreturn lexicographically ordered lists.dict_from_entriesprocesses its input list from first to last, so the last tuple for a duplicate key wins.
Dict implements Functor and Filterable. map, filter, and filter_map apply their callbacks
to values only and preserve the corresponding input keys. Callback applications for different
entries may evaluate in parallel; callback completion order does not affect the result.
dict_map has type ((String, a) -> (String, b)) -> Dict a -> Dict b. Its callback applications
may evaluate in parallel. After every callback completes, results are applied in the original
dictionary’s lexicographic key order. If multiple callbacks return the same output key, the result
from the latest input key in that order wins.
dict_filter has type ((String, a) -> Bool) -> Dict a -> Dict a. Its callback applications may
also evaluate in parallel. It preserves each accepted entry’s original key and value.
Primitive Host Types
The zero-arity primitive types are u8, u16, u32, u64, i8, i16, i32, i64,
f32, f64, Bool, Char, String, UUID, Hash, and DateTime. Char corresponds exactly
to Rust’s char and implements RexType, IntoRex, and FromRex. The Hash type corresponds to a
blake3::Hash in Rust. At JSON boundaries, hash values are exactly 32 bytes encoded as hexadecimal
strings; Rex emits the canonical lowercase 64-character representation. show uses the same
representation. The Parse Hash instance accepts the same representation and returns None for
invalid input.
Program Entry Points
A Rex source is a compilation unit with zero or more declarations and an optional final expression. Entry-point execution uses a single program entry point:
- If the source defines a top-level
fn main, that function is the entry point. It is an error for the same source to also contain a final expression. - If the source does not define
main, the final expression is treated as an implicit zero-argument entry point. - If the source does not define
mainand has no final expression, entry-point execution is an error.
The CLI supplies arguments to an explicit main from a JSON object passed with
--inputs. The object keys must exactly match the main parameter names, and
each value is converted with json_to_rex using the corresponding parameter
type. JSON inputs require concrete parameter types. A runnable source without
main uses its final expression as an implicit entry point and conceptually has
the empty input object {}.
Module Imports
Rex distinguishes between:
- program entry-point execution, and
- modules loaded by the import system.
When Rex source is loaded as a module via the module system, it must not contain a top-level expression result. Host-backed Rust modules may also be returned by importers; they expose their declared interface and native implementations without a Rex source body.
Syntax
Top-level imports support three forms:
import foo.bar as Bar;
import foo.bar (*);
import foo.bar (x, y, z as q);
Rules:
- Import declarations are terminated by explicit semicolons.
import <module> as <Alias>imports the module namespace and requires qualified access (Alias.member).import <module> (*)imports all exported values, types, and classes into unqualified scope.import <module> (x, y as z)imports selected exported values, types, and classes into unqualified scope.as <Alias>on the module and(...)import clauses are mutually exclusive.- A module identity is a validated qualified name with one or more segments. Each segment starts
with a letter or
_and then contains only letters, digits, or_. - The engine treats module identities as names in an abstract namespace. Mapping those names to
.rexfiles, generated source, parsed ASTs, databases, or Rust host modules is importer policy. - Imports are module names, not URLs, filesystem paths owned by the engine, or content hashes.
Visibility and Exports
Only exported (pub) values, types, and classes are importable through (*) and item clauses.
Module aliases expose all export namespaces for qualified lookup:
-
Alias.valueresolves against exported values. -
Alias.Typeresolves against exported type names in type positions. -
Alias.Type.Variantresolves an ADT constructor owned by an exported type. -
Alias.Classresolves against exported class names in class-constraint positions. -
Missing requested exports are module errors.
-
Private (non-
pub) values are not importable.
Name Binding and Conflicts
- Imported unqualified names participate in lexical shadowing.
- Lexically bound names (lambda params,
letvars, pattern bindings) shadow imported names. - Importing a name that conflicts with a local top-level declaration is a module error.
- Importing the same unqualified name more than once (including via aliasing) is a module error.
Importing an ADT type also makes its constructor namespace available. For example,
import dep (Status) permits Status.Ready, while import dep as D permits
D.Status.Ready. Constructor namespaces are type-owned, so two imported types may both define a
variant named Ready without creating an ambiguity. For compatibility, variants also retain their
legacy value aliases; an ambiguous legacy alias still requires the type-owned spelling.
Type/class rewrites run with declaration ordering semantics:
- In binder forms that carry type syntax (
\ (x : T) -> ...,let rec f : T = ...), the binder being introduced does not suppress alias resolution inside its own annotation. - Missing alias members used in type/class positions (function signatures, annotations,
whereconstraints, instance headers, and superclass clauses) are reported as module errors.
Module Initialization
- Importing a module does not execute arbitrary top-level expressions.
- Module initialization is declaration-driven: exported values/types/classes are registered from declarations, and import resolution rewrites references to canonical internal symbols.
- Source imports are parsed as
CompilationUnits, then converted intoCompilationPackages for module processing. Source-derived and prebuiltCompilationPackageimports are loaded through strongly connected component (SCC) loading of module interfaces, so cyclic source imports are supported. - Rust modules returned by importers are installed lazily through the same named-module machinery as
eager
Builder::inject_module. They must be named modules matching the resolved module identity, not root/global modules, and they do not run nested Rex import graph loading.
Let Rec Bindings
Syntax
Recursive bindings use let rec with comma-separated entries:
let rec
a = ...,
b = ...
in
body
Rules:
let recentries are separated by commas.let recbindings must bind variables (not arbitrary patterns).- A syntactic lambda binding is a recursive function binding. Type annotations around the lambda do not change this classification.
- Non-lambda bindings are value bindings. They are initialized sequentially and may only reference
earlier bindings in the same
let recgroup. - Function bodies may reference any binding in the same
let recgroup. - A value binding is rejected if it depends on itself, a later binding, or an earlier function whose body can reach a binding that is not initialized yet.
Top-Level Declaration Terminators
Top-level type, fn, declare fn, and import declarations are terminated by explicit
semicolons:
import math.core as Math;
type Box a = Box a;
type Point = { x: i32, y: i32 };
fn inc x: i32 -> i32 = x + 1;
declare fn host_value : i32;
Rules:
- The semicolon terminates the declaration, not a nested type or expression itself.
- The terminating semicolon is found at top-level expression/type depth; semicolons nested inside parentheses, brackets, braces, or blocks do not terminate the declaration.
- Indentation and newlines do not delimit declarations.
Explicit Type Parameters
Type variables used in annotations, constraints, class heads, or instance heads must be declared by the syntactic form that binds them.
Examples:
type Box a = Box a;
fn id<a> x: a -> a = x;
declare fn host_id<a> x: a -> a;
let id<a>: a -> a = \x -> x in id 1
class Size a where { size : a -> i32; }
instance<a> Show (List a) <= Show a where { show = prim_show; }
Rules:
- A bare unknown type name is an error, even when it starts with a lowercase letter.
- Top-level
fn,declare fn, namedlet, class methods, and instance methods bind type parameters with<...>after the value name. typeandclassdeclarations bind type parameters with whitespace after the declaration head.instancedeclarations bind type parameters with<...>immediately afterinstance.
Algebraic Data Type Constructors
Each ADT owns a constructor namespace named after the type:
type Direction = North | South;
type Status = Ready | Waiting;
let direction = Direction.North in
match direction with {
case Direction.North -> Status.Ready;
case Direction.South -> Status.Waiting;
}
The same Type.Variant spelling is used in expressions and constructor patterns. Constructor
names are distinct from record projection: when the left-hand name resolves to an ADT type, the
member resolves in that type’s variant namespace. The runtime constructor tag remains the variant
name, so this source-level qualification does not change ADT JSON encoding or Rust conversion.
Unqualified constructor aliases remain available for source compatibility. They may be overloaded
when multiple ADTs define the same variant; Type.Variant is the canonical, unambiguous spelling.
Named Record Aliases
Syntax
A type declaration whose right-hand side begins with a record type declares a transparent record
alias rather than an ADT:
type Point = { x: i32, y: i32 };
type Tagged a = { tag: String, value: a };
Typing and Runtime Representation
- Applying a record alias is equivalent to writing its expanded record type. Alias names do not make otherwise identical record shapes distinct.
- Alias parameters are substituted structurally, so
Tagged i32expands to{ tag: String, value: i32 }. - Aliases may refer to other aliases and ADTs. Cyclic aliases are rejected.
- A record alias introduces no value-level constructor and no runtime tag. Its values are ordinary record/dict values.
- A record literal checked against a record type receives the expected type of each field. This
permits heterogeneous and nested literals such as
let user: { name: String, age: i32 } = { name = "Ada", age = 36 } in user.
Default-Backed Record Construction
Syntax and Application Precedence
A record-carrying ADT constructor may be followed directly by its named fields:
Config.Config { retries = 9, enabled = true }
An uppercase constructor reference and its following record literal bind as one expression before surrounding function application. This also applies to qualified constructors, so the final argument below does not require parentheses:
run_tool input Tools.Options { retries = 9 }
Complete Construction
When every declared field is supplied, construction has the ordinary ADT semantics and does not
require a Default instance:
type Config = Config { retries: i32, enabled: Bool };
Config.Config { retries = 9, enabled = true }
Partial Construction
Fields may be omitted when all of these conditions hold:
- The constructor belongs to a single-variant ADT.
- Its only argument is a record payload.
- A
Defaultinstance is available for the resulting ADT type.
The constructor fixes the result type and complete field schema before supplied fields are checked. Therefore partial construction does not rely on row polymorphism or infer a smaller record type. Unknown fields remain errors.
For a qualifying constructor T.C, this expression:
T.C { field = value }
is semantically equivalent to evaluating default once at T.C’s result type and updating that
value’s field. Omitted fields come from the whole ADT’s Default implementation, not from
independent Default instances for each field. T.C {} is an explicitly typed default value.
Partial construction is rejected for multi-variant ADTs because Default T does not guarantee
that it produces the named variant. Supporting such construction would require a
constructor-specific default rather than the type-level Default instance.
Top-Level fn Recursion
Top-level fn declarations are mutually recursive within a module.
This means:
- A top-level
fnmay reference itself. - A top-level
fnmay reference other top-levelfndeclarations in the same module, regardless of declaration order.
Operationally, top-level fn recursion follows the same fixed-point semantics as recursive
bindings in let rec, but at declaration scope.
Record Projection
Syntax
Field projection is an expression:
base.field
Typing (Definite Fields)
Let Γ ⊢ base : T. Projection is well-typed iff the field is definitely available on T:
- If
Tis a record type{ ..., field : τ, ... }, thenΓ ⊢ base.field : τ. - If
Tis a single-variant ADT whose payload is a record containingfield : τ, thenΓ ⊢ base.field : τ. - If
Tis a multi-variant ADT, projection is accepted only if the typechecker can prove the current constructor is a specific record-carrying variant (typically viamatchrefinement or by tracking known constructors through let-bound variables).
If the typechecker cannot prove the constructor for a multi-variant ADT, the field is considered “not definitely available”, and projection is rejected.
Evaluation
Evaluation is strict in base. At runtime, projection reads the field out of the record payload:
- for plain records/dicts, it indexes the map by the field symbol.
- for record-carrying ADT values, it indexes the payload record/dict.
Missing fields are a runtime error (EngineError::UnknownField) when projection is attempted on a
non-record-like value.
Record Update
Syntax
Record update is an expression:
{ base with { field1 = e1, field2 = e2 } }
Typing (Definite Fields)
Let Γ ⊢ base : T. Record update is well-typed iff:
- Each updated field exists on the definite record shape of
T. Tis one of:- a record type
{ field: Ty, ... }, OR - a single-variant ADT whose payload is a record, OR
- a multi-variant ADT after the expression has been refined to a specific record-carrying constructor (the typechecker tracks this refinement).
- a record type
- For each update
fieldᵢ = eᵢ, the update expression unifies with the declared field type.
If the base type is a multi-variant ADT and the typechecker cannot prove the current constructor, record update is rejected (the field is “not definitely available”).
Typing: Known-Constructor Refinement
The typechecker refines “which constructor is known” via two mechanisms:
- Pattern matching: within a
case K { ... } -> ...arm, the scrutinee is known to beK. - Let-bound known constructors: when a variable is bound to a value constructed with a record-carrying constructor, the variable may carry “known variant” information forward.
This enables the common pattern:
type Sum = A { x: i32 } | B { x: i32 };
let s: Sum = Sum.A { x = 1 } in
match s with {
case Sum.A {x} -> { s with { x = x + 1 } };
case Sum.B {x} -> { s with { x = x + 2 } };
}
Evaluation
Evaluation is strict:
- Evaluate
baseto a value. - Evaluate all update expressions (left-to-right in the implementation’s map iteration order).
- Apply updates:
- If
baseis a plain record/dict value, updates replace existing fields. - If
baseis an ADT whose payload is a record/dict, updates replace fields in the payload and re-wrap the constructor tag.
- If
Runtime errors:
- Updating a non-record-like runtime value is
EngineError::UnsupportedExpr.
Type Classes: Coherence, Resolution, and Ambiguity
Instance Coherence (No Overlap)
For each class C, instance heads are non-overlapping:
- When injecting a new instance head
H, it is rejected if it unifies with any existing head for the same classC.
This forbids overlap and preserves deterministic method resolution.
Regression: spec_typeclass_instance_overlap_is_rejected (rex/tests/spec_semantics.rs).
Qualified Class Names in instance Headers
The class name in an instance header may be qualified through a module alias:
import dep as D;
instance D.Pick i32 where {
pick = 7;
}
The alias member must be an exported class from the referenced module; otherwise import-use validation fails before typechecking/evaluation.
Method Resolution (Runtime)
At runtime, class methods are resolved by unification against the inferred call type.
Let m be a class method, and let its call site be typed with monomorphic call type τ_call.
Resolution:
- Determine the “instance parameter type” for the method by unifying
τ_callwith the method’s scheme and extracting the predicate corresponding to the method’s defining class. - If the instance parameter type is still headed by a type variable (not ground enough to pick an
instance), the use is ambiguous:
- If
mis used as a function value (i.e.τ_callis a function type), the engine returns an overloaded function value and defers resolution until the function is applied with concrete arguments. - If
mis used as a value (non-function), the engine errors (EngineError::AmbiguousOverload).
- If
- If exactly one instance head unifies with the instance parameter type, its method body is specialized and evaluated.
- If none match, the engine errors (
EngineError::MissingTypeclassImpl). - If more than one match (should not occur given non-overlap), the engine errors
(
EngineError::AmbiguousTypeclassImpl).
Regression: spec_typeclass_method_value_without_type_is_ambiguous (rex/tests/spec_semantics.rs).
Prelude Parsing
The prelude class Parse a provides parse : String -> Option a. It has instances for Bool,
u8, u16, u32, u64, i8, i16, i32, i64, f32, f64, Char, UUID, Hash, and
DateTime. A successful conversion returns Some value; malformed or out-of-range input returns
None rather than raising an evaluation error. Parsing a Char succeeds only when the input
contains exactly one Unicode scalar value.
The desired result type must be determined by context so that the corresponding Parse instance
can be selected, for example:
let port: Option u16 = parse "8080" in port
Regressions: parse_returns_some_for_every_supported_type and
parse_returns_none_for_every_supported_type (rex/tests/typeclasses_system.rs).
Overloaded Method Values (Deferred Resolution)
If a class method is used as a function value, the engine may defer instance selection until the function is applied with concrete argument types. This supports idioms like:
let f = map ((+) 1) in
( f [1, 2, 3]
, f (Some 41)
)
Here f is polymorphic over the Functor dictionary; at each call site, the engine resolves
map using the argument type (List i32 vs Option i32) and dispatches to the corresponding
instance method body.
Prelude List Additive Monoid
The prelude defines AdditiveMonoid (List a) for every element type a, with no constraint on
a. Its identity zero is [], and xs + ys concatenates xs and ys in that order.
[1, 2, 3] + [4, 5, 6]
Regression: additive_monoid_list_concatenates_in_order and
additive_monoid_list_requires_no_element_constraint (rex/tests/typeclasses_system.rs).
Prelude Ordering
The prelude defines the algebraic data type Ordering with exactly three unit variants:
Ordering.Less, Ordering.Equal, and Ordering.Greater. The Ord method
cmp : a -> a -> Ordering returns the variant that describes the left operand relative to the
right operand. Floating-point comparisons involving NaN remain runtime type errors.
Regression: ord_cmp_returns_ordering_variants and ordering_variants_can_be_pattern_matched
(rex/tests/typeclasses_system.rs).
Prelude List Ranges
The prelude exposes strict list range helpers:
first n xsreturns the firstnvisible elements ofxs.last n xsreturns the lastnvisible elements ofxs.slice n m xsreturns the half-open visible rangen..m.
For all three helpers, list positions are counted from the Rex-level list view, independent of
whether the runtime stores the list as cons cells, a vector-backed slice, or cons cells followed by
a vector-backed slice. Bounds are checked at runtime. Negative bounds, bounds greater than the list
length, and slice n m xs with m < n are runtime errors. n == length/m == length is valid for
empty suffixes and half-open slice endpoints.
The Sequence methods take and skip use a u64 count. Counts greater than the list length are
clamped to the list length, so take returns the whole list and skip returns an empty list.
The list-specific operations use u64 positions and return ordinary data failures as options:
list_get index xsreturnsSomefor an in-bounds zero-based index andNoneotherwise.list_slice start end xsreturnsSomeof the half-open rangestart..endwhen0 <= start <= end <= length xs, includingSome []for an empty valid range, andNonefor invalid bounds.list_reverse,list_concat, andlist_repeatpreserve element values and construct a new list in the order implied by their names.list_repeat 0 valuereturns[].list_any,list_all,list_find, andlist_find_indexevaluate predicates from left to right and stop once their result is known. On an empty list,list_anyisfalse,list_allistrue, and both find operations returnNone.list_countandlist_partitionevaluate every predicate application. Independent applications may run concurrently.list_partitionreturns matching elements first and rejected elements second, preserving relative input order in both lists.
Instance-Method Checking (Static)
Inside an instance method body, only the instance context is available as “given” constraints:
- Given predicates start with the instance’s explicit context.
- The superclass closure of that context is added (repeat until fixed point).
- The instance head itself is also considered given (dictionary recursion).
Rules:
- Ground predicates required by the method body must be entailed by the given set (via instance search).
- Non-ground predicates are not resolved by instance search (that would be unsound); they must appear explicitly in the instance context.
This is what makes instance methods predictable and prevents “magical” selection based on unifying type variables with arbitrary instance heads.
Integer Literals
Integer literals are overloaded over integral types.
- A literal like
4introduces a fresh type variableαwith predicateIntegral α. - A negative literal like
-3introducesαwith predicatesIntegral αandAdditiveGroup α(so it can only specialize to signed numeric types). - Binary subtraction uses
Subtractive, which includes unsigned integer types. Unary negation still requiresAdditiveGroup. - Division uses
Divisive, which includes primitive integer and floating-point types. Integer division follows Rust’s integer division semantics. - Integer
+,-,*,/, and%are checked at runtime for primitive integer types. Arithmetic overflow raisesinteger overflow (T)and arithmetic underflow raisesinteger underflow (T), whereTis the concrete integer type. - Context can specialize
α(for example,let x: u64 = 4 in x). - Unannotated
letbindings whose definition is an integer literal are kept monomorphic. This lets use sites specialize the binding consistently in that scope (for example,let x = 4 in (x + 1, x + 2)). - If
αremains ambiguous, normal defaulting rules apply.
Examples:
let x: u8 = 4 in x
let f: i64 -> i64 = \x -> x in f 4
let x = 4 in (x is u16)
let x: i16 = -3 in x
Attempting to use a negative literal at an unsigned type is a type error (for example
let x: u8 = -3 in x).
Implicit Integer Widening
Rex inserts an implicit integer widening conversion only when the surrounding expression context already requires a concrete primitive integer type.
- The source and target must both be primitive integer types.
- The conversion must be lossless for every value of the source type.
- The target type must already be known; Rex does not infer a common numeric type for unconstrained mixed-width expressions.
Allowed widening conversions are:
i8 -> i16,i8 -> i32,i8 -> i64i16 -> i32,i16 -> i64i32 -> i64u8 -> u16,u8 -> u32,u8 -> u64,u8 -> i16,u8 -> i32,u8 -> i64u16 -> u32,u16 -> u64,u16 -> i32,u16 -> i64u32 -> u64,u32 -> i64
Examples:
fn f : i32 -> i32 = \x -> x;
fn g : i8 -> i8 = \x -> x;
let x: i32 = (7 is i8) in (f (g 5), x)
Mixed operators remain homogeneous unless an enclosing context fixes the target type. For example,
(1 is i8) + (2 is i32) is a type error.
Float Literals
Float literals are overloaded over primitive floating-point types.
- A literal like
3.0introduces a fresh type variableαwith predicateField α. - Context can specialize
αtof32orf64(for example,let x: f64 = 3.0 in x). - If
αremains ambiguous, normal defaulting rules choosef32. - Unannotated
letbindings whose definition is a float literal are kept monomorphic, matching integer literal bindings. - Float literals do not imply integer-to-float coercions. A mixed expression such as
1 + 2.0is still a type error unless the values are explicitly converted by user code.
Examples:
let x: f64 = 3.0 in x
let f: f64 -> f64 = \x -> x in f 3.0
let x = 3.0 in (x is f32)
Defaulting
Defaulting runs after type inference and before evaluation.
Eligible Variables
A type variable α is eligible for defaulting iff:
αappears in at least one simple predicate of the formC α, and at least one suchCis in the numeric defaultable set:AdditiveMonoid,MultiplicativeMonoid,Subtractive,AdditiveGroup,Ring,Divisive,Field,Integral; and- every simple predicate involving
αis either in that numeric set or is an allowed companion.
Eq and Ord are allowed as companion predicates when a numeric defaultable predicate is also
present. They do not make a type variable defaultable on their own. This lets expressions like
if x == 0.0 then ... default through the float literal’s Field predicate without making
unconstrained equality default to an arbitrary numeric type.
Compound predicates do not make a variable eligible for defaulting. They also do not prevent an otherwise eligible numeric variable from defaulting, provided that substituting a candidate makes each compound predicate ground and the resulting predicate is satisfied. A candidate is rejected if substitution leaves another unresolved variable in one of those predicates.
Candidate Types (Order Matters)
The candidate list is constructed in this order:
- Traverse the typed expression (depth-first) and collect every concrete (ground) 0-arity type constructor that appears as the type of a subexpression (unique, in first-seen order).
- Append (if not already present):
f32,i32,String.
Choosing a Default
For an eligible variable α, choose the first candidate type T such that substituting T for
α makes every predicate involving α ground and satisfied in the empty context:
entails([], Pᵢ[α := T]) for every predicate Pᵢ involving α
If no candidate satisfies all predicates, α remains ambiguous.
Example: zero (type α with AdditiveMonoid α) defaults to f32 when no other concrete type is
present:
zero
Regression: spec_defaulting_picks_a_concrete_type_for_numeric_classes (rex/tests/spec_semantics.rs).
For example, the integer literals below provide the simple predicate Integral α, while list
addition provides the compound predicate AdditiveMonoid (List α). Substituting i32 satisfies
both, so the result type is List i32:
[1, 2, 3] + [4, 5, 6]
Regressions: spec_defaulting_accepts_satisfied_compound_predicates and
spec_defaulting_requires_a_simple_numeric_predicate (rex/tests/spec_semantics.rs).
Architecture
Rex is implemented as a small set of focused crates that form a pipeline:
- Parsing (
rex-parser): converts source text into arex_ast::CompilationUnit { decls, body }. - Typing (
rex-typesystem): Hindley–Milner inference + ADTs + type classes; produces arex_typesystem::TypedExpr. - Execution (
rex-engine): builds the host environment, prepares typed code into aCompiledProgram, and evaluates it to an ownedrex_engine::Value.
The crates are designed so you can use them independently (e.g. parser-only tooling, typechecking-only checks, or embedding the full evaluator).
Crates
rex-ast: shared AST types (Expr,Pattern,Decl,TypeExpr,CompilationUnit, symbols, spans).rex-parser: source parser. Entry point:rex_parser::parse.- Parsing enforces a fixed cap on AST nesting.
rex-typesystem: type system. Entry points:TypeSystem::new()to create an explicit typing environment.infer_typed(&mut ts, expr)/infer(&mut ts, expr)for type inference.- The inference implementation itself lives in
rex-typesystem/src/inference.rs;typesystem.rsnow holds the shared core types, environments, and registration logic. - For untrusted code, set
rex_typesystem::TypeSystemLimits::safe_defaults()before inference. RegisteredValuecarries a scheme, Rex-visible parameter names, and optional Markdown docs.TypeBundleis the JSON-facing persistence format for those registrations and their referenced, documented ADTs.
rex-engine: host environment builder, compiler, and runtime evaluator. Entry points:Builder::with_prelude(state)?to inject runtime constructors and builtin implementations (statecan be()).standard_type_system()?to create a typing environment with therex-enginestandard prelude.Builder::build_compiler()to consume the prepared builder into a compilation view.Compiler::compile_programto consume the compiler and prepare a parsed program entry point into(CompiledProgram, Evaluator).Compiler::infer_*consumes the compiler for type-only checks.Evaluator::run(compiled, inputs).awaitto execute one prepared program.inputsis aBTreeMap<String, Value>for the program’s externalmaininterface;runconsumes the evaluator, compiled program, and input map and returns aValue.Buildercarries host state asBuilder<State>(State: Clone + Send + Sync + 'static); typedexportcallbacks receive an owned clone ofStateand returnResult<T, EngineError>, typedexport_asynccallbacks receive an owned clone ofStateand returnFuture<Output = Result<T, EngineError>>, while dynamic native APIs (export_native*) receiveContext<State>, an instantiated type, and ownedValues.- compile and evaluation APIs return
EngineError; convenience entry points that cross phases returnExecutionError. - Host module injection API:
Module+Export+Builder::inject_modulefor eager registration, orImporter<State>returningResolvedModuleContent::module(...)for lazy Rust-backed modules. Named modules accept optional Markdown docs inModule::new.
rex-proc-macro:#[derive(Rex)],#[rex::export], and#[rex::module]bridge documented Rust types, functions, and modules into Rex registrations.rex: top-level facade for embedding the pipeline in Rust applications. Itsworkflowmodule provides typed workflow modules, CAS artifacts, and mandatory OCI tool execution. Semantic tool plans become logicalOciJobvalues; Docker is the supplied backend andOciJobExecutoris the provider extension point. See OCI Executor Protocol.rex-cli:rex_clicommand-line front-end around the pipeline.rex-lsp/rex-vscode: editor tooling.
rex-engine is organized internally around the same phases:
builder/owns builder-facing host/module registration.compiler/owns program preparation, import rewriting, typechecking, module loading state, andCompiledProgramconstruction.evaluator/owns execution, scheduling, native dispatch,Context, and the runtime core.modules/,memory/, andconfig.rshold shared module identities, heap values/GC roots, and runtime options.
Runtime Ownership and GC Boundaries
rex-engine uses a moving copying collector. Heap has one owner and moves exactly once
through Builder -> Compiler -> Evaluator; there is no heap mutex or public heap capability.
Private heap cells contain moving edges, while evaluator frames, environments, scheduler work, and
compiler state contain stable runtime-root tokens. The evaluator traverses and relocates those
roots only at actual collection safepoints, not around every work item.
The public boundary is an owned Value tree. Before a host call starts, the evaluator performs a
type-directed copy from internal roots to Value; the host future contains only those values and
host state. On completion, the evaluator validates and imports the result while it again has
exclusive heap access. Internal prelude intrinsics are deliberately separate: they operate
synchronously through the private runtime/root scope and never use the host Value ABI.
This separation also leaves the internal representation free for a future LLVM backend and custom
binary heap. Generated code can eventually use private tagged values and participate in the same
GC safepoint/root protocol without changing host handlers or treating Value as a JIT ABI.
See Memory Management for the reference categories, allowed conversions, and collector invariants.
Design Notes
- Typed preparation:
rex-engineprepares code into a typed form before execution. The currentCompiledProgramstores a typed AST plus the environment snapshot needed to run it. - Single-shot execution: evaluation is intentionally one-shot.
CompiledProgramis moved intoEvaluator::runwith its runtime input map, consuming the evaluator as well. Prepare all required declarations/modules before constructing or consuming the evaluator. - Single-use preparation:
Builder::build_compiler(),Compiler::compile_program, and theCompiler::infer_*APIs consume their receivers. Each program run should create a fresh builder/compiler/evaluator lineage. - Same-lineage runtime model: a
CompiledProgramis intended to run on the evaluator produced from the same compiler. Rex programs are supplied as source and compiled per run, so Rex does not expose a portable compiled-artifact or cross-runtime linking model. - Phase ownership:
Builderowns embedder configuration only untilbuild_compiler().Compilerthen owns preparation state: the type environment, runtime declaration environment, runtime registries, heap, module loader caches, and execution policy snapshot.Evaluatoris built only by consuming that compiler, so runtime code cannot mutate modules or compile new declarations. - Prelude ownership:
rex-engineowns the standard prelude source, standard typing environment, and runtime contract. The split is:- typeclass and instance declarations written in Rex at
rex-engine/src/prelude/typeclasses.rex rex-engine/src/prelude/type_system.rsbuilds the prelude-enabledTypeSystem, including ADTs, parsed declarations, and primop schemesrex-engine/src/prelude/mod.rsparses the Rex source and injects runtime method bodies/native implementations forBuilder::with_prelude(state)?rex-typesystemexposes generic registration/inference APIs and does not own the standard prelude
- typeclass and instance declarations written in Rex at
- Depth bounding: Some parts of the pipeline are naturally recursive (parsing deeply nested parentheses, matching deeply nested terms). The parser enforces a fixed AST-depth cap, and the typechecker-limit API provides bounded recursion for production/untrusted workloads.
- Import-use rewrite/validation: module processing resolves import aliases across expression vars, constructor patterns, type references, and class references; unresolved qualified alias members are rejected as module errors before runtime.
- Abstract module namespace: a
ModuleIdis a validated qualified Rex name such asstd.preludeorffmpeg.formats.av1. It does not encode a filesystem path, origin kind, URL, or content hash. Importers decide whether a module name maps to a file, database row, in-memory string, open editor buffer, generated AST, or Rust host module. - Importer payloads and caching: importers are generic over host state and can return Rex
source, a prebuilt
CompilationPackage, or a Rust-backedModule<State>. Source imports are parsed intoCompilationUnitvalues, then converted intoCompilationPackagefor compiler module processing. Source-derived and prebuilt packages are loaded through the SCC module graph path. Rust-backed modules are installed lazily through the same internal module installer used by eagerinject_module, and are self-contained host modules rather than Rex source modules with nested import loading. Importer results are cached for one compile so the same request is not resolved repeatedly.
Intentional String Boundaries
Rex now prefers structured internal representations (for example NameRef, BuiltinTypeId,
CanonicalSymbol, and module/type/class maps) across parser, type system, evaluator, and LSP
rewrite paths. Remaining string usage is intentional in these boundary layers:
- Source text and parsing: the parser accepts source strings by definition.
- Human-facing diagnostics and display: error messages, hover text, CLI rendering, and debug output stringify symbols/types for readability.
- Protocol/serialization boundaries: JSON/LSP payloads are string-based and convert structured internal symbols/types at the edge.
- Module specifiers: parsed import names are textual before being resolved into validated
ModuleIdvalues and handed to importer policy.
Non-goal for this pass:
- Eliminating all
.to_string()calls globally. The design target is to avoid stringly-typed core semantics, not to remove string conversion at UI/protocol boundaries.
OCI Executor Protocol
Rex compiles typed external-tool requests into ToolExecutionPlan values and
then resolves them to executor-neutral OciJob values. Docker is the only
production backend shipped by Rex. The protocol exists so a host can add a
remote OCI service without changing Rex modules or exposing host execution.
An OciJob contains only:
- a host-selected OCI image and target
os/architecture[/variant]; - a trusted command supplied by the installed tool and symbolic arguments;
- declared CAS blob or tree inputs and declared output slots;
- mandatory resource and result limits; and
- an explicit isolation policy.
It cannot carry a developer-machine path, arbitrary mount, environment override, backend option, device, or secret.
Logical workspace and CAS transfer
Input and output paths are logical slot numbers. A backend chooses its private filesystem paths or service objects after dispatch. Declared blob inputs are transferred by bytes. Tree inputs are transferred recursively while preserving entry names, kinds, and BLAKE3 identities.
After completion, a backend returns only hashes assigned to declared output
slots. The caller verifies their kinds, total size, and CAS identities before
making them visible to the workflow. Missing completion records, undeclared
slots, wrong object kinds, changed hashes, excessive output, and malformed
provenance are result-protocol failures. A nonzero tool exit remains an
ordinary ToolExecution result.
Required capabilities
OciExecutorCapabilities declares controls a target can actually enforce.
The shared validator rejects a job before dispatch if the target cannot provide
any requested guarantee. The secure default requires:
- disabled networking;
- a read-only image root and read-only inputs;
- non-root execution, dropped capabilities, and no-new-privileges behavior;
- no devices, secrets, or additional mounts; and
- execution, stream, output, temporary-storage, memory, CPU, and PID limits.
A managed container product that cannot enforce a required control is not a
conforming target for that job. An adapter must return Unsupported; it must
never silently weaken policy.
Platform, images, and provenance
The executor reports its target platform. Every selected image must target that exact platform. Production image configuration requires a digest-qualified OCI reference; mutable tags are restricted to explicit local image development.
Every successful OCI execution includes ToolExecutionProvenance identifying
the executor, target platform, immutable image digest, declared input hashes,
and output hashes. This record makes the execution and data boundary auditable;
it is not service attestation by itself.
Implementing a backend
A provider adapter implements OciJobExecutor. It owns authentication,
submission, scheduling, polling, cancellation, private workspace or object
storage, CAS transfer, and service cleanup. It must call or reproduce the
shared validation contract before starting work and must pass
workflow_oci_executor_conformance.
FakeRemoteOciExecutor is an in-memory protocol test double. It uses a CAS
separate from the caller and proves that the boundary does not depend on Docker
bind mounts. It is not a cloud executor: it provides no transport,
authentication, multi-tenant isolation, durable remote storage, or service
attestation.
Installable tool binaries
rex::workflow discovers typed tool modules at runtime. Give the CLI a directory with
--tool-dir DIRECTORY or REX_TOOL_DIR; an import of tools.NAME looks for the executable
rex-tool-NAME in that directory. Tools that are not imported are neither inspected nor started.
When neither setting is present, the CLI searches beside its own executable.
Installing a tool means copying its executable into that directory. Removing it makes the module
unavailable on the next workflow compilation. rex does not link to tool crates, so this
does not require recompiling the workflow host.
Binary contract
Every tool is a separate Rust crate named rex-tool-NAME and its binary implements two commands:
rex-tool-NAME manifest
rex-tool-NAME execute FUNCTION [JSON]
If the JSON argument to execute is omitted, the binary reads it from standard input. Arguments
may be an object keyed by the documented parameter names or a positional array. A successful
command writes only JSON to standard output; diagnostics go to standard error and failures return
a nonzero status.
manifest returns protocol version 1, the exact Rex module ID, and a stable TypeBundle:
{
"protocolVersion": 1,
"module": "tools.example",
"typeBundle": {
"docs": "Module documentation.",
"values": {
"ping": [{
"scheme": { "type": {
"kind": "fun",
"params": [{ "kind": "builtin", "name": "String" }],
"ret": { "kind": "builtin", "name": "String" }
} },
"params": ["message"],
"docs": "Function documentation."
}]
},
"adts": []
},
"defaults": []
}
The bundle contains every public function scheme, Rex-facing parameter name, module/function/type
documentation, and every ADT owned by the module. defaults preserves concrete host-backed
Default instances used by record construction and update. Protocol version 1 requires each
function to have one non-overloaded, concrete type scheme because JSON cannot represent unresolved
type variables.
For execute, the shared runner uses json_to_rex for every argument, invokes the ordinary typed
module handler, and uses rex_to_json for its result. The generated handler therefore performs the
normal FromRex conversions before entering Rust and IntoRex conversion on return. The proxy in
The rex::workflow proxy uses the same two JSON conversion functions, so direct binary calls and calls from a
workflow have identical representations.
Authoring a tool
A tool crate depends on rex, defines an ordinary Rust-backed module, and gives
that module factory to the shared command runner:
use rex::{
engine::{EngineError, Module},
workflow::state::State,
};
fn module() -> Result<Module<State>, EngineError> {
// Usually generated by #[rex::module] and #[rex::export].
api::rex_module()
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
rex::workflow::tool_protocol::run_tool_cli(
module,
|| rex::workflow::tool_protocol::default_tool_state(
"example",
"REX_TOOL_EXAMPLE_IMAGE",
"rex-tool-example:local",
),
)
.await
}
The bundled examples are the six workspace crates rex-tool-ffmpeg, rex-tool-gnuplot,
rex-tool-graphviz, rex-tool-imagemagick, rex-tool-poppler, and rex-tool-qpdf. Their Rust
types derive Rex; exported async functions receive State, convert typed requests into OCI jobs,
and return typed results.
Use State::store and the helpers in rex::storage for content-addressed blobs and trees. Use
State::execute_tool with a ToolExecutionPlan containing the tool’s trusted command. A tool can
instead construct an OciJob and invoke an OciJobExecutor such as
DockerOciJobExecutor; the public job boundary provides the same CAS staging, output validation,
limits, and isolation controls.
default_tool_state opens the filesystem CAS named by REX_STORE and configures the Docker OCI
backend for the tool name, image variable, and development default supplied by that tool crate.
Embedding hosts with a different deployment model may supply a different state factory to
run_tool_cli.
Trust boundary
An installed tool binary is native code and is therefore trusted to the same degree as the Rex workflow host. The directory must not be writable by untrusted workflow authors. Rex source cannot select an arbitrary binary: the requested module ID determines one filename, and the binary’s manifest must declare that exact module and a supported protocol version.
The native binary is only the typed protocol adapter. External media, plotting, document, or scientific programs should still run through the OCI executor rather than directly on the host. Workflow source controls typed function arguments, not image references, mounts, executable paths, or Docker options.
Memory Management
rex-engine stores evaluator data in a private moving heap. The builder creates
Heap, then ownership moves through the single-use pipeline:
Builder -> Compiler -> Evaluator
There is no public heap capability and no heap mutex. At any moment one task owns the complete runtime and has exclusive access to allocation and collection. The owning evaluator future may migrate between executor threads, but two threads cannot access the same heap concurrently.
Representation boundaries
The runtime deliberately separates three concerns:
| Representation | Purpose | Visibility |
|---|---|---|
Value | Owned semantic data passed to and from hosts | Public; no heap references |
RootedPtr | Stable token for evaluator/compiler state | Crate-private |
InternalPtr | Moving edge stored inside heap cells | Memory implementation only |
Value is a host interchange model, not the evaluator’s internal value or a
future JIT ABI. Its collection variants recursively own other Values.
Evaluator-only states such as closures, native and overloaded functions, and
uninitialized cells cannot be exported.
Value::List represents ordinary Rex lists. Value::Bytes is the canonical
host representation of Rex List U8. Type-directed conversion returns
Bytes for every physical list layout, including empty lists, cons chains,
data-backed slices, binary-data slices, and mixed cons/slice values.
Runtime roots
An InternalPtr contains a heap identity, slot, and collection generation.
It is valid only until an allocation that may collect unless it is already a
traced cell edge or represented by a runtime root.
A RootedPtr is a stable generational token into the runtime root table. It
does not expose a cell address. Collection rewrites the table entry when an
object moves, allowing evaluator frames, environments, scheduler work, module
values, and compiler state to retain the token. RootScope provides exclusive
synchronous access for inspecting and allocating values and explicitly roots
temporary results.
Machine-owned roots remain registered while they are present in evaluator or compiler state. At a collection safepoint the runtime traverses the current frames, environments, and scheduler state, removes stale root tokens, and then runs the collector. This traversal occurs only when collection is required; the evaluator does not persist and reconstruct its complete state around each ready work item.
Copying collection
Collection begins from the live runtime-root table. It traces private
InternalPtr edges through reachable cells, copies reachable cells, and
rewrites cell edges and runtime-root entries to the new generation. Debug
builds verify that copied slots have the current generation, contain valid
children, and remain reachable.
The object allocation path also protects child edges already placed in a pending cell. It temporarily registers those edges as runtime roots, collects, rewrites the pending cell from the relocated roots, releases the temporary entries on both success and failure, and only then installs the cell.
Extreme GC stress is available through a builder test setting. It collects at every evaluator safepoint and randomizes destinations so tests exercise root relocation rather than relying on stable slot numbers.
Host calls
Public host functions never receive Heap, RootScope, RootedPtr, or a
capability that can obtain them. Public Context retains only host state and
type-system metadata, not runtime registries or their root tokens. The call boundary is:
private heap --type-directed copy--> owned Value
owned Value --host work/future-----> owned Value
owned Value --validate and import--> private heap
Arguments are converted before a host callback is invoked. A synchronous
dynamic callback owns Vec<Value>; an async callback future owns the same
heap-independent data plus host state. When the result completes, the
evaluator validates it against the instantiated Rex result type and imports it
while it again has exclusive runtime access. Cancellation drops the owning
runtime and host futures without acquiring a lock or unregistering roots from
another thread.
The conversion kernel validates scalar and composite shapes, tuple and ADT arity, record fields, constructor identity, and concrete element types. It uses an explicit work stack for recursive data, so deeply nested ADTs do not consume the Rust call stack. List traversal is specialized and iterative.
Host-provided constants are imported once during module installation and
retained as internal rooted constants. Looking one up does not schedule a host
call or reconvert an owned Value.
Internal intrinsics
Prelude operations and generated constructors are not host functions. They
use a crate-private intrinsic ABI that receives an active root scope and may
inspect or allocate internal values directly. Higher-order intrinsics may
return evaluator-managed work that applies Rex closures. This distinction is
important: only genuinely external host work pays for conversion to and from
Value.
Future runtimes
The current heap uses Rust cells, but no public API exposes their layout.
Future LLVM-generated sequential regions can use private tagged values and a
custom binary heap while preserving the same host boundary. Compiled code
that may allocate will need to report live references at safepoints through
stack maps, statepoints, or a compatible shadow-root mechanism. Value will
remain the host interchange tree rather than becoming the generated-code
calling convention.
No unsafe code is used by the current memory model.
Embedding Rex in Rust
Rex is designed as a small pipeline you can embed at whatever stage you need:
rex-parser: source →CompilationUnit { decls, body }rex-typesystem: HM inference + type classes →TypedExpr(plus predicates/type)rex-engine: build host modules, compile typed code intoCompiledProgram, then run it →rex_engine::Value
This document focuses on common embedding patterns.
Running Untrusted Rex Code (Production Checklist)
This repo provides language-level parsing limits and a pure evaluator suitable for embedding. Your production server is responsible for enforcing hard resource limits (process isolation, wall-clock timeouts, memory limits).
Recommended defaults for untrusted input:
- Parsing enforces a fixed AST-depth cap.
- Run evaluation in an isolation boundary you can hard-kill (separate process/container), with CPU/RSS/time limits.
Evaluation API:
- Evaluation is async via
Evaluator.
Compile Then Run
rex-engine now has an explicit preparation boundary:
Builderbuilds the host environment.Compilerprepares user code into aCompiledProgram.Evaluatorowns the runtime core and runs one prepared program with runtime inputs formain.
The whole builder/compiler/evaluator lineage is single-use. Builder::build_compiler() consumes
the builder, Compiler::compile_program and Compiler::infer_* consume the compiler, and
Evaluator::run consumes the evaluator, the compiled program, and a BTreeMap<String, Value> of
inputs. Programs are compiled with Rex’s singular external interface semantics: an explicit
fn main ... defines named runtime inputs, while a final expression without main is treated as
an implicit zero-input main.
use rex::{
engine::{CompileOptions, Builder},
parser::parse,
};
let builder = Builder::with_prelude(())?;
let compiler = builder.build_compiler();
let parsed = parse("let x = 1 + 2 in x * 3").map_err(|errs| format!("{errs:?}"))?;
let (program, evaluator) = compiler
.compile_program(&parsed, CompileOptions::for_module("workflow.main")?)
.await?;
assert_eq!(program.result_type().to_string(), "i32");
let value = evaluator.run(program, Default::default()).await?;
What “compiled” means in the current design:
- parsing, import rewriting, declaration injection, and typechecking have already happened
CompiledProgramcarries a typed expression plus the environment snapshot needed to run itCompiledProgram::main_signature()reports input names/types and the external result typeEvaluatorowns the runtime core needed for executionEvaluator::runconsumes the evaluator, compiled program, and runtime input map; use a new builder/compiler/evaluator lineage for another generated workflow
What is captured:
- Rex declarations that are part of the prepared program are captured into the compiled env snapshot
- host-provided exports registered through
export,export_async,export_native,export_native_async, orexport_valueare carried by the evaluator produced from the same compiler - typeclass method bindings are carried by that same evaluator runtime
That means a CompiledProgram is intended to be run by the evaluator created from the same
compiler. Rex does not currently expose a portable compiled artifact or cross-runtime linking
model.
Phase-specific errors:
CompilerAPIs returnEngineErrorEvaluator::runreturnsEngineError- APIs that parse, compile, and run in one call return
ExecutionErrorbecause they cross phase boundaries
Runtime Values and Heap Ownership
Rex uses a moving copying collector, but the heap is entirely private and has one owner. External
main inputs and evaluation results are owned Value trees containing no heap references.
Composite variants recursively contain Value; closures, native functions, overloaded functions,
and uninitialized cells cannot cross this boundary and produce a conversion error.
Value::List represents ordinary lists. Value::Bytes is the required representation for Rex
List U8, including empty lists and lists whose internal representation mixes cons cells and
vector-backed slices. Bytes is an embedding optimization, not a distinct Rex language type.
Host functions receive owned values before they start and return owned values that are validated and imported only after completion. Async host futures therefore contain no heap capability. See Memory Management for the complete internal ownership model.
Compile parsed Rex sources with Compiler::compile_program and pass the resulting
CompiledProgram to Evaluator::run.
Evaluate Rex Code Directly
use rex::{
engine::{Builder, CompileOptions},
parser::parse,
};
let program = parse("let x = 1 + 2 in x * 3").map_err(|errs| format!("{errs:?}"))?;
let builder = Builder::with_prelude(())?;
let compiler = builder.build_compiler();
let (program, evaluator) = compiler
.compile_program(&program, CompileOptions::for_module("workflow.main")?)
.await?;
let value = evaluator.run(program, Default::default()).await?;
println!("{value}");
Rex source modules loaded via importers must be declaration-only. To run an expression, use snippet
or program entry points.
Qualified alias members used in type/class positions (annotations, where constraints, instance
headers, superclass clauses) are validated against module exports during module processing; missing
exports fail early with module errors.
Builder Initialization and Default Imports
Builder::with_prelude(state) is shorthand for Builder::with_options(state, EngineOptions::default()).
- Prelude is enabled by default.
std.preludeis default-imported.- Default imports are weak: they fill missing names, but never override local declarations or explicit imports.
If you want full control:
use rex::engine::{Builder, EngineOptions, PreludeMode};
let mut builder = Builder::with_options(
(),
EngineOptions {
prelude: PreludeMode::Disabled,
default_imports: vec![],
},
)?;
Inject Modules (Embedder Patterns)
This is fully supported in rex-engine. You can compose module loading from:
- the bundled
std.preludevirtual module - modules injected with
Builder::inject_module - Rust modules returned lazily by importers
- custom async importers (for DB/object-store/in-memory modules)
1) Use an Explicit Importer
rex-engine does not read module files from disk by default. File-backed loading is a host
policy decision; the CLI installs its own filesystem importer, while embedded applications should
provide an importer that matches their trust boundary.
Use DenyImporter when you need an explicit importer implementation that rejects every module
request.
Notes:
- importers receive an
ImportRequestwith the requestedModuleIdand optional importing module id. - a
ModuleIdis a qualified namespace name, not a filesystem path; the CLI’s filesystem mapping is one importer policy, not a core engine rule. - snippets and parsed programs load Rex source modules through the compiler’s import rewriting path; source-backed modules remain declaration-only.
- importer results are cached for one compile, so the same request is not sent through the importer chain repeatedly.
- import clauses (
(*)/ item lists) import exported names into unqualified scope. - unqualified imports are context-sensitive: expression positions use values, type positions use types, and class/constraint positions use classes.
- module aliases (
import x as M) provide qualified access to exported values, types, and classes. - importing a name only brings in the facets that actually exist under that name.
2) Inject In-Memory Rex Modules
For host-managed modules, either call Builder::inject_module or add an importer that maps
module IDs to source text or prebuilt compilation units.
use futures::future::BoxFuture;
use rex::{
engine::{
CompileOptions, Builder, ImportRequest, Importer, ResolvedModule, ResolvedModuleContent,
},
parser::parse,
};
use std::collections::HashMap;
use std::sync::Arc;
let mut builder = Builder::with_prelude(())?;
let modules = Arc::new(HashMap::from([
(
"acme.math".to_string(),
"pub fn inc : i32 -> i32 = \\x -> x + 1;".to_string(),
),
(
"acme.main".to_string(),
"import acme.math (inc);\npub fn main : i32 = inc 41;".to_string(),
),
]));
struct MapImporter {
modules: Arc<HashMap<String, String>>,
}
impl Importer for MapImporter {
fn import<'a>(
&'a self,
req: ImportRequest,
) -> BoxFuture<'a, Result<Option<ResolvedModule>, rex::engine::EngineError>> {
Box::pin(async move {
let module_name = req.module_id.to_string();
let Some(source) = self.modules.get(&module_name) else {
return Ok(None);
};
Ok(Some(ResolvedModule {
id: req.module_id,
content: ResolvedModuleContent::Source(source.clone()),
}))
})
}
}
builder.add_importer(Arc::new(MapImporter { modules }));
let compiler = builder.build_compiler();
let parsed = parse("import acme.main (main);\nmain").map_err(|errs| format!("{errs:?}"))?;
let (program, evaluator) = compiler
.compile_program(&parsed, CompileOptions::for_module("workflow.main")?)
.await?;
let value = evaluator.run(program, Default::default()).await?;
println!("{value}");
3) Host-Provided Rust Functions, Exposed as Modules
This is the common embedder case.
Use Module + Builder::inject_module(...):
- Create a
Module. - Add exports:
- typed exports with
export/export_async - runtime/native exports with
export_native/export_native_async - optional structured declarations with
add_rex_adt/add_adt_decl - optional typeclass instances for existing classes with
add_instance
- typed exports with
- Inject it into the builder.
Module::add_rex_adt::<T>() now stages the full acyclic ADT family reachable from T.
This is driven by RexType::collect_rex_family(...): ADT types contribute declarations there,
while leaf Rex types inherit a no-op default. For example, if Label contains a Side, staging
Label is enough; you do not need to stage Side separately. Cyclic ADT families are still
rejected.
Module is intentionally narrower than a general Rex declaration package. Embedders can stage
host-provided ADTs, host exports, and instances of existing typeclasses. Arbitrary Rex declarations
belong in CompilationPackage, not Module. Type declarations come from the staged ADTs; call
Module::declarations() when you need the derived Declarations package view used by the
compiler.
export handlers are fallible and must return Result<T, EngineError>. If a handler returns
Err(...), evaluation fails with that engine error.
export_async handlers follow the same rule, but return
Future<Output = Result<T, EngineError>>.
Both forms receive owned arguments copied from the evaluator heap. The returned owned value is
validated and imported in a later evaluator cycle. Synchronous handlers resume through an
immediately-ready native completion; they do not consume async-native permits or pass through
AsyncCallPolicy. They run on the evaluator task, so blocking or long-running work belongs in an
asynchronous export.
use rex::{
engine::{CompileOptions, Builder, Module},
parser::parse,
};
let mut builder = Builder::with_prelude(())?;
let mut math = Module::new(
"acme.math",
Some("Arithmetic operations provided by the host.".to_owned()),
);
math.export("inc", |_state: (), x: i32| { Ok(x + 1) })?;
math.export_async("double_async", |_state: (), x: i32| async move { Ok(x * 2) })?;
builder.inject_module(math)?;
let compiler = builder.build_compiler();
let parsed = parse("import acme.math (inc, double_async as d);\ninc (d 20)")
.map_err(|errs| format!("{errs:?}"))?;
let (program, evaluator) = compiler
.compile_program(&parsed, CompileOptions::for_module("workflow.main")?)
.await?;
let value = evaluator.run(program, Default::default()).await?;
println!("{value}");
For API surfaces primarily consumed by agents, use the registration attributes so Rust doc comments become Rex metadata automatically:
/// Arithmetic tools exposed by this host.
#[rex::module(name = "acme.math", defaults(Input))]
mod math {
use rex::engine::EngineError;
/// A value supplied to arithmetic operations.
#[derive(Clone, Default, rex::Rex)]
#[rex(export)]
pub struct Input {
/// The integer to operate on.
pub value: i32,
}
/// Increment an input by one.
#[rex::export(name = "inc")]
pub fn increment(_state: (), input: Input) -> Result<Input, EngineError> {
Ok(Input { value: input.value + 1 })
}
}
let mut builder = rex::engine::Builder::with_prelude(())?;
builder.inject_module(math::rex_module()?)?;
#[rex::module] generates rex_module(). It copies the module’s Rust doc comments, collects
functions marked #[rex::export], and stages non-generic derived ADTs marked #[rex(export)].
#[rex::export] supports synchronous and asynchronous functions, preserves the function’s Rust
doc comments and Rex-visible parameter names, and generates a <function>_rex_export() helper.
Every derived ADT reachable through an exported function’s argument or result types is staged
automatically, including ADTs nested inside standard containers.
The optional defaults(Type, ...) module argument stages a qualified Rex Default instance for
each listed concrete Rust type. A listed type must implement RexDefault<State> and IntoRex;
ordinary Rust Default types receive RexDefault through its blanket implementation. The native
value producer is private, while the instance becomes available when the named module is imported.
This permits explicitly typed option construction such as tool Options {} or
tool Options { retries = 3 }. Omitted fields come from the registered default.
Rust does not allow #[doc] attributes or doc comments on individual function parameters.
Parameter descriptions therefore belong in the function-level doc comment; Rex metadata stores
only each parameter’s Rust identifier. The host-state parameter is not part of the Rex signature
or its parameter-name metadata.
The lower-level APIs remain available for dynamic registration. Pass module documentation as the
second argument to Module::new; use Export::with_docs and Export::with_param_names for an
export; set AdtDecl::docs; and pass variant documentation to AdtDecl::add_variant. AdtParam,
AdtArgument, and AdtField carry documentation for the corresponding parts of an ADT. Repeated
ADT registrations merge missing documentation and reject contradictory documentation for the
same declaration. Named modules preserve generic-parameter documentation through their
intermediate TypeDecl using documented TypeParam entries. Rustdoc itself does not render
generic-parameter documentation, so put #[allow(unused_doc_comments)] on a documented generic
parameter when warnings are denied.
Module::global() keeps its no-argument signature and creates the root module without module-level
documentation.
Before injection, metadata can be inspected through Module::docs, Module::exports (using each
export’s docs() and params() methods), and Module::adts. After registration, the same function
and ADT metadata lives on RegisteredValue and AdtDecl entries in the builder or compiler’s
TypeSystem.
Documentation is also preserved in the JSON-facing TypeBundle wire format. A bundle can carry
explicit bundle-level docs, documented overloads and parameter names, and documented ADTs down to
type parameters, variants, constructor arguments, and record fields.
TypeBundle::from_registered_values preserves the value docs and parameter names in its
RegisteredValue inputs, but leaves the bundle-level docs field unset; call
TypeBundle::with_docs when the bundle itself needs docs.
TypeBundle::from_schemes has no value docs to preserve and generates names such as arg0 for
function parameters. The manifest builder currently uses this latter path. Wire parameter metadata
is a list of strings because individual parameters have no documentation of their own. The wire
format intentionally has no schema-version constant or schemaVersion field. When persisting a
virtual module as a bundle, its module-level docs can be stored in this top-level bundle field.
TypeBundle::into_parts returns a DecodedTypeBundle with named docs, adts, and values
fields. TypeBundle::register_into installs those ADTs and returns a RegisteredTypeBundle with
named docs and values fields.
You can declare ADTs directly inside an injected host module:
use rex_ast::Symbol;
use rex_engine::{Builder, Module};
use rex_typesystem::types::{AdtArgument, BuiltinTypeId, Type};
let mut builder = Builder::with_prelude(())?;
let mut m = Module::new(
"acme.status",
Some("Status values returned by host operations.".to_owned()),
);
let mut status = builder.adt_decl("Status", &[]);
status.docs = Some("The state of a host operation.".to_owned());
status.add_variant(
Symbol::intern("Ready"),
vec![],
Some("The operation completed successfully.".to_owned()),
);
status.add_variant(
Symbol::intern("Failed"),
vec![AdtArgument::Positional {
typ: Type::builtin(BuiltinTypeId::String),
docs: Some("A human-readable failure message.".to_owned()),
}],
Some("The operation failed.".to_owned()),
);
m.add_adt_decl(status)?;
builder.inject_module(m)?;
Then Rex code can import and use those names from the module:
import acme.status (Status, Failed);
let fail: String -> Status = \msg -> Failed msg in
match (fail "boom") with {
case Failed msg -> length msg;
case _ -> 0;
}
Status is used here in type position, while Failed is used in expression/pattern positions.
They are imported through the same name-based mechanism.
Internally this generates module declarations and injects host implementations under qualified module export symbols.
If you need to construct exports separately (for example to build a module from plugin metadata), you can use:
Export::from_handler/Export::from_async_handler(typed handlers)Export::from_native/Export::from_native_async(value-based native handlers)
These constructors initially use generated parameter names such as arg0 and have no docs. Chain
Export::with_param_names and Export::with_docs when supplying API metadata, then add the export
with Module::add_export. Adding it also stages any derived ADT family required by the export’s
Rust signature.
This example shows how to use Rust enums and structs as Rex-facing types with ADTs declared inside
the module itself. The host function accepts a Rust Label (containing a Rust Side enum), and
Rex code calls it through sample.render_label.
Example:
use rex::{
Rex,
engine::{CompileOptions, Builder, EngineError, Module},
parser::parse,
};
#[derive(Clone, Debug, PartialEq, Rex)]
enum Side {
Left,
Right,
}
#[derive(Clone, Debug, PartialEq, Rex)]
struct Label {
text: String,
side: Side,
}
fn render_label(label: Label) -> String {
match label.side {
Side::Left => format!("{:<12}", label.text),
Side::Right => format!("{:>12}", label.text),
}
}
let mut builder = Builder::with_prelude(())?;
let mut m = Module::new("sample", None);
m.add_rex_adt::<Label>()?;
m.export("render_label", |_state: (), label: Label| {
Ok::<String, EngineError>(render_label(label))
})?;
builder.inject_module(m)?;
let compiler = builder.build_compiler();
let parsed = parse(
r#"
import sample (Label, Left, Right, render_label);
(
render_label (Label { text = "left", side = Left }),
render_label (Label { text = "right", side = Right })
)
"#,
)
.map_err(|errs| format!("{errs:?}"))?;
let (program, evaluator) = compiler
.compile_program(&parsed, CompileOptions::for_module("workflow.main")?)
.await?;
let value = evaluator.run(program, Default::default()).await?;
println!("{value}"); // ("left ", " right")
In that example:
Labelis imported once and then used as both a type name and a constructor value.LeftandRightare imported as constructor values.render_labelis imported as a value.
3a) Runtime-Defined Signatures (Value APIs)
If your host determines function signatures/behavior at runtime, use the native module export
APIs and provide an explicit Scheme + arity:
Module::export_nativeModule::export_native_async
These callbacks receive Context<State> (not just &State), so they can:
- read state via
ctx.state() - inspect typed call information via the explicit
&Type/Typecallback parameter
Async native callbacks receive owned argument vectors and return Send + 'static futures. The
host scheduler owns and polls those futures while the evaluator retains exclusive heap ownership.
Context retains only shared host state and type-system metadata; it does not retain evaluator
registries, runtime roots, or another indirect heap capability.
Synchronous native callbacks use the same Context/Value boundary and completion machinery,
but produce an immediately-ready result. They are not subject to async admission or executor
policy and should remain short and nonblocking.
A callback owns its Vec<Value> arguments and may move an argument directly into its result. It
may also construct a new owned Value; it cannot inspect or allocate in the evaluator heap.
use futures::FutureExt;
use rex_engine::{Builder, Context, Module, Value};
use rex::typesystem::{BuiltinTypeId, Scheme, Type};
let mut builder = Builder::with_prelude(())?;
let mut m = Module::new("acme.dynamic", None);
let scheme = Scheme::new(vec![], vec![], Type::fun(Type::builtin(BuiltinTypeId::I32), Type::builtin(BuiltinTypeId::I32)));
m.export_native("id_value", scheme.clone(), 1, |_ctx: Context<()>, _typ: &Type, mut args: Vec<Value>| {
Ok(args.remove(0))
})?;
m.export_native_async("answer_async", Scheme::new(vec![], vec![], Type::builtin(BuiltinTypeId::I32)), 0, |_ctx: Context<()>, _typ: Type, _args: Vec<Value>| {
async move { Ok(Value::I32(42)) }.boxed()
})?;
builder.inject_module(m)?;
Scheme and arity must agree. Registration returns an error if the type does not accept the
provided number of arguments.
3b) Lazy Rust Modules From Importers
If many Rust modules are available but most programs import only a few, an importer can build and
return a Module<State> on demand. This keeps Builder::inject_module as the eager path, while
letting embedders defer expensive module construction until Rex code actually imports that module.
use futures::future::BoxFuture;
use rex::{
engine::{
Builder, CompileOptions, EngineError, ImportRequest, Importer, Module, ModuleId,
ResolvedModule, ResolvedModuleContent,
},
parser::parse,
};
use std::sync::Arc;
#[derive(Clone)]
struct ToolImporter {
tools_id: ModuleId,
}
impl Importer for ToolImporter {
fn import<'a>(
&'a self,
req: ImportRequest,
) -> BoxFuture<'a, Result<Option<ResolvedModule>, EngineError>> {
Box::pin(async move {
if req.module_id != self.tools_id {
return Ok(None);
}
let mut tools = Module::new(self.tools_id.to_string(), None);
tools.export("inc", |_state: (), x: i32| Ok(x + 1))?;
Ok(Some(ResolvedModule {
id: self.tools_id.clone(),
content: ResolvedModuleContent::module(tools),
}))
})
}
}
let mut builder = Builder::with_prelude(())?;
let tools_id = ModuleId::parse("workflow.tools")?;
builder.add_importer(Arc::new(ToolImporter { tools_id }));
let compiler = builder.build_compiler();
let parsed = parse("import workflow.tools (inc);\ninc 41")
.map_err(|errs| format!("{errs:?}"))?;
let (program, evaluator) = compiler
.compile_program(&parsed, CompileOptions::for_module("workflow.main")?)
.await?;
let value = evaluator.run(program, Default::default()).await?;
println!("{value}");
Lazy Rust-module rules:
- the returned
Module<State>must use the sameStatetype as the builder/compiler. - the module must be a named module, not
Module::global(). - the module’s qualified name must match the returned
ResolvedModule.id. - lazy Rust modules are installed through the same internal path as eager named
Builder::inject_module, so exports, module-local ADTs, type declarations, caches, and native runtime registrations behave the same. - a lazy Rust module is self-contained host code; the engine does not run the Rex source SCC loader over imports inside it.
4) Custom Importer Contract (Advanced)
If you need dynamic/nonstandard module loading behavior, implement Importer<State>.
Importer contract:
- return
Ok(Some(ResolvedModule { ... }))when you can satisfy the module. - return
Ok(None)to let the next importer try. - return
Err(...)for hard failures (invalid module payload, policy violations, etc.).
ResolvedModule<State> can carry:
ResolvedModuleContent::Source(...)for Rex source text.ResolvedModuleContent::CompilationPackage(...)for preconstructed structured Rex modules.ResolvedModuleContent::module(...)for a Rust-backedModule<State>installed lazily.
5) Snippets That Import Relative Modules
If you evaluate ad-hoc Rex snippets that contain imports, give the snippet an
explicit module name in CompileOptions. Importers receive that name as
ImportRequest::importer and decide how requested module IDs map to files, databases, in-memory
source, Rust modules, or any other backing store. The core engine treats module IDs as names in an
abstract namespace; filesystem-relative behavior is an importer policy.
use rex::{
engine::{CompileOptions, Builder},
parser::parse,
};
let builder = Builder::with_prelude(())?;
let compiler = builder.build_compiler();
let parsed = parse("import foo.bar as Bar;\nBar.add 1 2")
.map_err(|errs| format!("{errs:?}"))?;
let (program, evaluator) = compiler
.compile_program(&parsed, CompileOptions::for_module("workflow.snippet")?)
.await?;
let value = evaluator.run(program, Default::default()).await?;
Builder State
Builder is generic over host state: Builder<State>, where
State: Clone + Send + Sync + 'static.
The state is owned by the builder, moved into the compiler/runtime lineage, and shared across all
injected functions.
- Use
Builder::with_prelude(())?if you do not need host state. - If you do, pass your state struct into
Builder::new(state)orBuilder::with_prelude(state). export/export_asynccallbacks receive an owned clone ofStateas their first parameter.- Value-based native APIs (
export_native*) receiveContext<State>so they can readctx.state(); the context deliberately exposes no heap access.
use rex_engine::{Builder, Module};
#[derive(Clone)]
struct HostState {
user_id: String,
roles: Vec<String>,
}
let mut builder: Builder<HostState> = Builder::with_prelude(HostState {
user_id: "u-123".into(),
roles: vec!["admin".into(), "editor".into()],
})?;
let mut globals = Module::global();
globals.export("have_role", |state, role: String| {
Ok(state.roles.iter().any(|r| r == &role))
})?;
builder.inject_module(globals)?;
List Interop at Host Boundaries
Rex exposes one collection type to user code: List a. Rust Vec<T> values
convert to and from List T, so host functions can accept list literals and
return list values without explicit representation conversions.
accept_bytes [1, 2, 3]
where accept_bytes is exported from Rust with a Vec<u8> parameter.
Internally, lists may be represented either as linked Cons/Empty cells or
as a slice over contiguous heap data. That choice is not exposed to Rex code:
list constructors, list literals, pattern matching, and prelude collection
functions all operate on the same List a abstraction.
For Vec<u8>, Rex uses a binary data backing so host byte buffers do not need
one heap allocation per byte. The Rex type is still List u8, and host
functions accepting Vec<u8> can read lists backed by binary data, ordinary
list data, or cons cells followed by either backing.
match bytes with {
case Cons head _ -> head;
case Empty -> 0;
}
Typecheck Without Evaluating
use rex::{
engine::standard_type_system,
parser::parse,
typesystem::infer,
};
let program = parse("map (\\x -> x) [1, 2, 3]").map_err(|errs| format!("{errs:?}"))?;
let mut ts = standard_type_system()?;
for decl in &program.decls {
match decl {
rex_ast::Decl::Type(d) => ts.register_type_decl(d)?,
rex_ast::Decl::Class(d) => ts.register_class_decl(d)?,
rex_ast::Decl::Instance(d) => {
ts.register_instance_decl(d)?;
}
rex_ast::Decl::Fn(d) => ts.register_fn_decls(std::slice::from_ref(d))?,
}
}
let body = program
.body
.as_ref()
.expect("snippet must contain a final expression");
let (preds, ty) = infer(&mut ts, body.as_ref())?;
println!("type: {ty}");
if !preds.is_empty() {
println!(
"constraints: {}",
preds.iter()
.map(|p| format!("{} {}", p.class, p.typ))
.collect::<Vec<_>>()
.join(", ")
);
}
Type Classes and Instances
Users can declare new type classes and instances directly in Rex source. As the host, you:
- Parse Rex source into
CompilationUnit { decls, body }. - Inject
Decl::Class/Decl::Instanceinto the type system (if you’re typechecking without running). - Compile the full program through
Compiler(if you’re running), so instance method bodies are available at runtime.
Typecheck: Inject Class/Instance Decls into TypeSystem
use rex::{
engine::standard_type_system,
parser::parse,
typesystem::infer,
};
let code = r#"
class Size a where {
size : a -> i32;
}
instance<t> Size (List t) where {
size = \xs ->
match xs {
case Empty -> 0;
case Cons _ rest -> 1 + size rest;
};
}
size [1, 2, 3]
"#;
let program = parse(code).map_err(|errs| format!("{errs:?}"))?;
let mut ts = standard_type_system()?;
for decl in &program.decls {
match decl {
rex_ast::Decl::Type(d) => ts.register_type_decl(d)?,
rex_ast::Decl::Class(d) => ts.register_class_decl(d)?,
rex_ast::Decl::Instance(d) => {
ts.register_instance_decl(d)?;
}
rex_ast::Decl::Fn(d) => ts.register_fn_decls(std::slice::from_ref(d))?,
}
}
let body = program
.body
.as_ref()
.expect("snippet must contain a final expression");
let (_preds, ty) = infer(&mut ts, body.as_ref())?;
assert_eq!(ty.to_string(), "i32");
Evaluate: Inject Decls into Builder
use rex_engine::{Builder, CompileOptions};
use rex::parser::parse;
let code = r#"
class Size a where {
size : a -> i32;
}
instance<t> Size (List t) where {
size = \xs ->
match xs {
case Empty -> 0;
case Cons _ rest -> 1 + size rest;
};
}
(size [1, 2, 3], size [])
"#;
let program = parse(code).map_err(|errs| format!("{errs:?}"))?;
let builder = Builder::with_prelude(())?;
let compiler = builder.build_compiler();
let (compiled, evaluator) = compiler
.compile_program(&program, CompileOptions::for_module("workflow.main")?)
.await?;
let _ty = compiled.result_type().clone();
let value = evaluator.run(compiled, Default::default()).await?;
println!("{value}");
Inject Native Values and Functions
rex-engine is the boundary where Rust provides implementations for Rex values.
For host-provided modules, prefer Module + inject_module (above). For root-scope values
or functions, use Module::global() and inject that staged module into the builder.
use rex_engine::{Builder, Module};
let mut builder = Builder::with_prelude(())?;
let mut globals = Module::global();
globals.export_value("answer", 42i32)?;
globals.export("inc", |_state, x: i32| { Ok(x + 1) })?;
builder.inject_module(globals)?;
Owned constants are converted and imported once when their module is installed. Reading a constant
does not invoke a host callback or repeat the Value boundary conversion.
Integer Literal Overloading with Host Natives
Integer literals are overloaded (Integral a) and can specialize at call sites. This works for
direct calls, let bindings, and lambda wrappers:
use rex::parser::parse;
use rex_engine::{Builder, CompileOptions, Module};
for code in [
"num_u8 4",
"let x = 4 in num_u8 x",
"let f = \\x -> num_i64 x in f 4",
] {
let mut builder = Builder::with_prelude(())?;
let mut globals = Module::global();
globals.export("num_u8", |_state: (), x: u8| Ok(format!("{x}:u8")))?;
globals.export("num_i64", |_state: (), x: i64| Ok(format!("{x}:i64")))?;
builder.inject_module(globals)?;
let program = parse(code).map_err(|errs| format!("parse error: {errs:?}"))?;
let compiler = builder.build_compiler();
let (compiled, evaluator) = compiler
.compile_program(&program, CompileOptions::for_module("workflow.main")?)
.await?;
let _ty = compiled.result_type().clone();
let value = evaluator.run(compiled, Default::default()).await?;
println!("{value}");
}
Negative literals specialize only to signed numeric types. For example, num_i32 (-3) is valid,
while num_u32 (-3) is a type error.
Float literals are similarly context-sensitive for primitive float widths. A literal such as 3.0
defaults to f32 when unconstrained, but specializes to f64 when passed to a native or Rex
function whose argument type is f64.
Async Natives
If your host functions are async, stage them in a module with export_async and run the compiled
program with Evaluator::run.
use rex::parser::parse;
use rex_engine::{Builder, CompileOptions, Module};
let mut builder = Builder::with_prelude(())?;
let mut globals = Module::global();
globals.export_async("inc", |_state, x: i32| async move { Ok(x + 1) })?;
builder.inject_module(globals)?;
let program = parse("inc 1").map_err(|errs| format!("parse error: {errs:?}"))?;
let compiler = builder.build_compiler();
let (compiled, evaluator) = compiler
.compile_program(&program, CompileOptions::for_module("workflow.main")?)
.await?;
let _ty = compiled.result_type().clone();
let v = evaluator.run(compiled, Default::default()).await?;
println!("{v}");
By default, admitted async host futures are polled inline by the evaluator. This keeps the runtime portable and avoids assuming a particular runtime, which is important for wasm embedders. Inline polling is fine for futures that are naturally non-blocking, but CPU-heavy or blocking work should be moved onto an executor supplied by the embedding application.
Admission, callback invocation, and future polling occur without lending out the evaluator heap.
Arguments and completed results are owned Values throughout suspension, so an async callback may
retain or move them without depending on a heap location.
Use set_parallelism_controller to decide when async host callbacks may be invoked. A
ParallelismController grants a NativeAsyncPermit for each admitted
async native call; the permit is held until that call completes. Controllers can therefore enforce
process-local limits, shared limits across several evaluators, or externally coordinated limits
backed by a cluster scheduler.
ExecutionBounds remains available as a fixed controller. Its max_ready_work value is only an
internal evaluator queue-pressure guard: it limits how many already-created Rex frames sit in the
active ready queue, but it does not reserve external compute capacity. Native async permits are the
backpressure mechanism for host jobs.
Use set_async_call_policy to wrap futures after they have been admitted. The policy decides where
an admitted future runs; the parallelism controller decides whether the host callback is allowed to
start yet.
use futures::FutureExt;
use rex_engine::{AsyncCallPolicy, Builder, EngineError, Module};
let mut builder = Builder::with_prelude(())?;
builder.set_async_call_policy(AsyncCallPolicy::executor_fn(|future| {
async move {
tokio::spawn(future)
.await
.map_err(|err| EngineError::Internal(format!("async host task failed: {err}")))?
}
.boxed()
}));
let mut globals = Module::global();
globals.export_async("inc", |_state, x: i32| async move { Ok(x + 1) })?;
builder.inject_module(globals)?;
The executor hook is intentionally generic rather than Tokio-specific. Native applications can use Tokio or any other Rust executor; wasm applications can keep the inline policy or adapt to browser task primitives in the host crate.
Parsing Limits
Parsing enforces a fixed AST-depth cap:
use rex::parser::parse;
let program = parse("(((1)))")
.map_err(|errs| format!("parse error: {errs:?}"))?;
Bridge Rust Types with #[derive(Rex)]
The derive:
- implements
RexType - implements
RexAdt - implements
Rex - implements
IntoRex - implements
FromRex - provides injection helpers through
Rex - provides ADT declaration helpers through
RexAdt - declares an ADT in the Rex type system
- injects runtime constructors (so Rex can build values)
- discovers and registers the full acyclic ADT family needed by the root type
The derive does not implement RexDefault; inject_rex_with_default is available only when the
type already provides that trait.
Rust doc comments on the derived type, type parameters, enum variants, tuple fields, and named
fields are copied into the generated AdtDecl. For a struct, the type’s docs also document its
single generated constructor variant.
Fields of type Vec<T> are exposed as List T and convert to/from Rex lists.
When constructing or updating derived records from Rex code, use list literals
directly for these fields.
Rust char is a built-in bridge type corresponding to Rex Char; it can be used directly in
injected function signatures and fields of derived types.
That means MyType::inject_rex(&mut builder)? is enough for acyclic graphs of derived ADTs. You do
not need to manually register dependencies in topological order. Cyclic ADT families are still not
supported by this registration path.
If a field uses a Rust type that participates in Rex value conversion but is not itself a Rex ADT
(for example a leaf type with manual RexType / IntoRex / FromRex impls), no extra
field annotation is required. Such leaf types inherit the default no-op family collection from
RexType, so derived ADTs can contain them without trying to register them as ADTs.
use rex::{
Rex,
engine::{Builder, EngineError, FromRex, IntoRex, Value},
typesystem::{RexType, Type},
};
#[derive(Debug, PartialEq)]
struct AtomRef(i32);
impl RexType for AtomRef {
fn rex_type() -> Type {
i32::rex_type()
}
}
impl IntoRex for AtomRef {
fn into_rex(self) -> Result<Value, EngineError> {
self.0.into_rex()
}
}
impl FromRex for AtomRef {
fn from_rex(value: Value) -> Result<Self, EngineError> {
Ok(Self(i32::from_rex(value)?))
}
}
#[derive(Rex, Debug, PartialEq)]
struct Fragment(Vec<AtomRef>);
let mut builder = Builder::with_prelude(())?;
Fragment::inject_rex(&mut builder)?;
use rex::{
Rex,
engine::{Builder, CompileOptions, FromRex},
parser::parse,
};
#[derive(Rex, Debug, PartialEq)]
enum Maybe<T> {
Just(T),
Nothing,
}
let mut builder = Builder::with_prelude(())?;
Maybe::<i32>::inject_rex(&mut builder)?;
let program = parse("Just 1").map_err(|errs| format!("parse error: {errs:?}"))?;
let compiler = builder.build_compiler();
let (compiled, evaluator) = compiler
.compile_program(&program, CompileOptions::for_module("workflow.main")?)
.await?;
let _ty = compiled.result_type().clone();
let v = evaluator.run(compiled, Default::default()).await?;
assert_eq!(Maybe::<i32>::from_rex(&v)?, Maybe::Just(1));
Register ADTs Without Derive
If your type metadata is data-driven (for example loaded from JSON), you can build ADTs
without #[derive(Rex)].
- Use
Builder::adt_decl_from_type(...)to seed an ADT declaration from a Rex type head. - Add variants with
AdtDecl::add_variant(name, args, docs), whereargsis aVec<AdtArgument>anddocsis anOption<String>. - Stage it with
Module::add_adt_decl(...), then inject that module withBuilder::inject_module(...).
Module::add_adt_decl(...) is the low-level single-ADT staging primitive. If you are building
several ADTs manually, prefer batching them in one module with add_adt_family(...).
use rex::{
ast::Symbol,
engine::{Builder, Module},
typesystem::{AdtArgument, RexType, Type},
};
let mut builder = Builder::with_prelude(())?;
let mut globals = Module::global();
let mut adt = builder.adt_decl_from_type(&Type::con("PrimitiveEither", 0))?;
adt.add_variant(
Symbol::intern("Flag"),
vec![AdtArgument::positional(bool::rex_type())],
None,
);
adt.add_variant(
Symbol::intern("Count"),
vec![AdtArgument::positional(i32::rex_type())],
None,
);
globals.add_adt_decl(adt)?;
builder.inject_module(globals)?;
If you have a Rust type with manual RexType/IntoRex/FromRex impls, implement
RexAdt and provide rex_adt_decl(). Then Builder::inject_rex_adt::<T>() gives manual
types the same registration workflow that #[derive(Rex)] exposes as T::inject_rex(...).
If the manual Rust type is itself an ADT, override RexType::collect_rex_family(...) and add its
AdtDecl there. Leaf types can inherit the default no-op implementation.
use rex::{
ast::Symbol,
engine::Builder,
typesystem::{AdtArgument, AdtDecl, RexAdt, RexType, Type, TypeError, TypeVarSupply},
};
struct PrimitiveEither;
impl RexType for PrimitiveEither {
fn rex_type() -> Type {
Type::con("PrimitiveEither", 0)
}
fn collect_rex_family(out: &mut Vec<AdtDecl>) -> Result<(), TypeError> {
out.push(<Self as RexAdt>::rex_adt_decl()?);
Ok(())
}
}
impl RexAdt for PrimitiveEither {
fn rex_adt_decl() -> Result<AdtDecl, TypeError> {
let mut supply = TypeVarSupply::new();
let mut adt = AdtDecl::new(&Symbol::intern("PrimitiveEither"), &[], &mut supply);
adt.add_variant(
Symbol::intern("Flag"),
vec![AdtArgument::positional(bool::rex_type())],
None,
);
adt.add_variant(
Symbol::intern("Count"),
vec![AdtArgument::positional(i32::rex_type())],
None,
);
Ok(adt)
}
}
let mut builder = Builder::with_prelude(())?;
builder.inject_rex_adt::<PrimitiveEither>()?;
Depth Limits
Some workloads (very deep nesting) can exhaust parser/typechecker recursion depth. Prefer bounded limits for untrusted code:
- parser AST depth
rex_typesystem::TypeSystemLimits::safe_defaults
Embedding workflow tool execution
The rex::workflow module does not execute operating-system tools directly on
the host. Workflow hosts must configure OCI images and either the supplied
Docker backend or an implementation of OciJobExecutor. There is no
host-process executor.
Provider implementations receive logical CAS inputs and output declarations, not host paths or Docker arguments. They must enforce the requested platform, isolation, resource limits, cancellation, result validation, and provenance contract described in OCI Executor Protocol.
Contributing
Workspace Layout
Rex is a Cargo workspace. The most important crates are:
rex-parser: source parsing into aCompilationUnit { decls, body }rex-ast: AST nodes, symbols, and spansrex-typesystem: Hindley–Milner inference + type classes + ADTsrex-engine: host environment building, compilation, evaluation, and native injectionrex-proc-macro: derives and registration attributes for documented Rust types, functions, and modules exposed to Rexrex: top-level embedding facade and integration testsrex-cli:rex_clicommand-line binary
Architecture overview: ARCHITECTURE.md.
Development
Run the full test suite:
cargo test
There is also a lightweight “fuzz smoke” test that runs a deterministic parse→infer→eval loop.
You can scale iterations with REX_FUZZ_ITERS:
REX_FUZZ_ITERS=2000 cargo test -p rex --test fuzz_smoke
Fuzz Harnesses
For end-to-end fuzzing with external fuzzers (AFL++, honggfuzz, custom mutational drivers), the
workspace includes rex-fuzz, a set of stdin-driven harness binaries:
cargo build -p rex-fuzz --bins
printf '1 + 2' | cargo run -q -p rex-fuzz --bin e2e
printf '(' | cargo run -q -p rex-fuzz --bin parse
Tuning knobs (environment variables):
REX_FUZZ_STACK_MB: stack size (MiB) for the harness thread
If you edit Rust code, also run:
cargo fmt
cargo clippy
Lockfiles
This repo commits:
Cargo.lock(workspace lockfile)rex-vscode/package-lock.json(VS Code extension)
Other lock-like files (for example under target/ or node_modules/) are build artifacts and
should not be committed.
LLMs
Introduction and Rationale
Rex includes a semantic assistance layer designed first for machine clients that generate code, especially LLM agents, and second for humans using an editor. This ordering is deliberate. LLMs are fast at proposing code but weak at maintaining a precise internal model of a language’s static semantics over many edits. A practical system therefore externalizes semantic reasoning into stable, tool-facing interfaces that can be queried repeatedly. Human users still benefit from the same machinery, but the core design target is iterative machine control: propose code, observe structured feedback, apply a constrained repair, and repeat.
A key design decision is to prioritize structured outputs over prose. Natural-language diagnostics are useful for people, but brittle for agents. Rex exposes semantic information and quick-fix data through explicit command contracts so that an LLM can operate as a controller over the typechecker and editor transformations rather than as a parser of unstructured text.
Typed Holes as a Control Primitive
The center of the workflow is the typed hole, written as ?. A hole allows partial programs to be
represented directly in source code. Instead of treating incompleteness as a syntax error, Rex keeps
the program parseable and infers constraints around the missing expression.
This shifts generation from “write final code in one pass” to “write a scaffold, then solve local obligations.” For LLMs, this is a better fit: the model can produce a coarse structure, ask for the expected type at the hole, retrieve candidate repairs, and select one.
fn parse_ph : String -> Result f32 String = \raw ->
if raw == "7.3" then Ok 7.3 else Err "bad reading";
fn classify_ph : f32 -> String = \ph ->
if ph < 6.8 then "acidic"
else if ph > 7.8 then "alkaline"
else "stable";
fn qc_label_from_sensor : String -> Result String String = \raw ->
match (parse_ph raw) with {
case Ok ph -> Ok (classify_ph ph);
case Err e -> Err e;
};
let sensor_reading = "7.3" in
let qc_label : Result String String = ? in
qc_label
In an LSP-enabled editor (including the browser playground), placing the cursor on ? exposes
hole-filling actions and semantic candidates such as qc_label_from_sensor sensor_reading. The
expected type at the hole is Result String String, so the model can fill a semantically meaningful
real-world step without guessing. The same machinery is consumed by VS Code and by external LLM
tooling.
Semantic Loop Endpoints
Rex provides semantic commands that return JSON-shaped data for program state at a position. The most important operation is a single semantic loop step, which reports expected and inferred types, in-scope values, candidate functions and adapters, local diagnostics, quick-fixes, and hole metadata.
From a control-systems viewpoint, this is an observation function over the current text. Separate commands apply an immutable quick-fix proposal bound to that text snapshot, or repeatedly apply best-ranked quick-fixes in bulk mode. Bulk mode also supports a dry-run option so agents can preview predicted text without committing edits.
The intended loop is simple: observe, choose, apply, re-observe. This structure is robust because it avoids fragile prompt-only planning and continuously re-anchors decisions in the compiler’s current state.
Candidate Narrowing and Adapter-Aware Repair
Candidate generation is hole-targeted and type-directed. Rex prefers functions whose result type can satisfy the local expected type and attempts to satisfy function arguments from in-scope values. When no direct value exists for an argument, Rex can propose single-step adapter expressions derived from in-scope functions.
This does not prove semantic correctness. It proves local type plausibility and improves search efficiency. The mechanism narrows the action space; it does not replace domain reasoning.
fn mk : i32 -> String = \n -> "value";
let x = 1 in
let y : String = ? in
y
In the editor, the hole can be filled with a candidate such as mk x, generated from local type
compatibility and in-scope bindings.
Bulk Repair, Dry Runs, and Contracts
Rex supports multi-step quick-fix application around a cursor location. Bulk repair is useful for agents because it can reduce several local errors in one command while returning telemetry about what changed at each step. Dry-run mode computes the same sequence but reports predicted output without mutating source text.
The semantic endpoints use a stable JSON contract with regression tests. This matters operationally: agents are software clients, and software clients break when response schemas drift. Contract tests convert “it usually works” into “it remains parseable across refactors.”
Resource Bounds and Adversarial Inputs
Semantic assistance can become expensive when scope size is large. To keep the system usable under load and safer for embedded deployments, Rex enforces explicit limits in semantic candidate pipelines, including caps on scanned environment schemes, in-scope values, candidate list sizes, and hole-report counts. This is a pragmatic defense against unbounded CPU and output growth in LSP-side analysis.
These bounds are not a complete security model. They should be combined with host-level timeouts, concurrency limits, memory limits, and request-rate controls in production embeddings.
Trying the Workflow in the Browser Playground
The interactive playground has full LSP support, so this chapter can be exercised directly in the browser. Paste a snippet with a hole, place the cursor on the hole, and inspect available quick-fixes and semantic suggestions.
fn parse_i32 : String -> Result String i32 = \s ->
if s == "42" then Ok 42 else Err "bad-int";
fn plus1 : i32 -> i32 = \n -> n + 1;
let input = "42" in
let out : Result String i32 = ? in
out
A useful exercise is to fill out in multiple ways, observe type errors, then invoke semantic
quick-fixes and compare outcomes.
Related Work and Positioning
The ideas used here are mostly established. Typed holes and goal-directed development are prominent in systems such as GHC (Haskell) and dependently typed environments like Agda and Idris. Live, structure-aware editor semantics have been explored in research systems such as Hazel. Type-directed code search and synthesis has a long line of work, including tools like InSynth and later synthesis frameworks.
Rex does not claim conceptual novelty in these foundations. Its contribution is engineering integration: one semantics pipeline serving both human editor workflows and LLM control loops, with contract-stable machine interfaces, regression coverage, and bounded candidate generation.
Reference: Semantic Assists
Rex exposes the following assists through LSP execute commands. Each assist is intended to be used in a short observe-then-act loop rather than as a one-shot oracle.
The argument forms below use JSON types and a 0-based Position.
Common types:
type UriArg =
| { uri: string }
| [uri: string];
type PosArg =
| { uri: string; line: u32; character: u32 }
| [uri: string, line: u32, character: u32];
type DiagnosticLite = {
message: string;
line: u32;
character: u32;
};
type QuickFixPrecondition = {
uri: string;
contentHash: string;
documentVersion: i32 | null;
};
type QuickFixProposal = {
protocolVersion: 2;
id: string;
title: string;
kind: string | null;
edit: WorkspaceEdit;
precondition: QuickFixPrecondition;
};
type HoleInfo = {
name: string;
line: u32;
character: u32;
expectedType: string;
};
rex.expectedTypeAt
args: PosArg
returns: null | { expectedType: string }
rex.functionsProducingExpectedTypeAt
args: PosArg
returns: { items: string[] } // each item rendered as "name : type"
rex.functionsAcceptingInferredTypeAt
args: PosArg
returns: {
inferredType: string | null;
items: string[];
}
rex.adaptersFromInferredToExpectedAt
args: PosArg
returns: {
inferredType: string | null;
expectedType: string | null;
items: string[];
}
rex.functionsCompatibleWithInScopeValuesAt
args: PosArg
returns: { items: string[] } // concrete call-style suggestions
rex.holesExpectedTypes
args: UriArg
returns: { holes: HoleInfo[] }
rex.semanticLoopStep
args: PosArg
returns: {
expectedType: string | null;
inferredType: string | null;
inScopeValues: string[];
functionCandidates: string[];
holeFillCandidates: Array<{ name: string; replacement: string }>;
functionsAcceptingInferredType: string[];
adaptersFromInferredToExpectedType: string[];
functionsCompatibleWithInScopeValues: string[];
localDiagnostics: DiagnosticLite[];
quickFixes: QuickFixProposal[];
quickFixTitles: string[];
holes: HoleInfo[];
}
rex.semanticLoopApplyQuickFixAt
args:
| { uri: string; quickFix: QuickFixProposal }
| [uri: string, quickFix: QuickFixProposal]
returns:
| { status: "applied"; quickFix: QuickFixProposal }
| {
status: "stale";
reason: "documentContentChanged" | "documentVersionChanged";
expectedContentHash: string;
actualContentHash: string;
expectedDocumentVersion: i32 | null;
actualDocumentVersion: i32 | null;
}
| {
status: "rejected";
reason: string;
detail?: string | null;
failedChange?: u32 | null;
}
Quick-fixes are immutable proposals bound to the document content and LSP version observed during
rex.semanticLoopStep. Applying a proposal validates its ID and snapshot preconditions, then asks
the editor to apply the exact versioned WorkspaceEdit returned by discovery. The server does not
regenerate candidates during application. A changed document therefore produces an explicit
stale result instead of applying a different edit or returning an ambiguous null.
rex.semanticLoopApplyBestQuickFixesAt
args:
| {
uri: string;
line: u32;
character: u32;
maxSteps?: u64;
strategy?: "conservative" | "aggressive";
dryRun?: bool;
}
| [
uri: string,
line: u32,
character: u32,
maxSteps?: u64,
strategy?: string,
dryRun?: bool
]
// maxSteps is clamped to [1, 20]
returns: {
strategy: "conservative" | "aggressive";
dryRun: bool;
appliedQuickFixes: QuickFixProposal[];
appliedCount: u64;
steps: Array<{
index: u64;
quickFix: QuickFixProposal;
diagnosticsBefore: DiagnosticLite[];
diagnosticsAfter: DiagnosticLite[];
diagnosticsBeforeCount: u64;
diagnosticsAfterCount: u64;
diagnosticsDelta: i64;
noImprovementStreak: u64;
}>;
updatedText: string;
localDiagnosticsAfter: DiagnosticLite[];
stoppedReason: string;
stoppedReasonDetail: string;
lastDiagnosticsDelta: i64;
noImprovementStreak: u64;
seenStatesCount: u64;
}
Practical Generation Guidance (Legacy Checklist)
The remainder of this chapter preserves the practical generation checklist that was previously a standalone LLM guidance page. It remains useful when an LLM is emitting Rex directly rather than running a semantic loop command at each step.
Recommended Context Order
When building or revising Rex code, read docs in this order:
- This chapter (
LLMS.md) for semantic-loop workflow and generation pitfalls. LANGUAGE.mdfor syntax and everyday feature usage.SPEC.mdfor locked behavior when edge cases matter.
High-Value Rules
- Use
fnfor top-level reusable functions; useletandlet recfor local helpers. - For local mutual recursion, use comma-separated
let recbindings. - Use
x::xsfor list cons in both patterns and expressions (x::xsis equivalent toCons x xs). - Validate snippets with the Rex CLI before shipping docs.
Quick Generation Checklist
Before returning generated Rex code:
- Put top-level reusable functions in
fndeclarations (they are mutually recursive). - Use
let reconly for local recursive helpers inside expressions. - Add annotations where constructor or numeric ambiguity is likely (
Empty,zero, overloaded methods). - Ensure the final expression returns a visible result (often a tuple for demos).
- Run
cargo run -p rex-cli --bin rex_cli -- /tmp/snippet.rexand fix all parse and type errors.
Syntax Pitfalls
1) Recursion model
- Top-level
fndeclarations are mutually recursive. - Top-level
typeandfndeclarations end with;; indentation and newlines do not terminate them. - Single recursive local helper:
let rec - Mutually recursive local helpers:
let recwith commas between bindings.
Top-level mutual recursion:
fn even : i32 -> Bool = \n ->
if n == 0 then true else odd (n - 1);
fn odd : i32 -> Bool = \n ->
if n == 0 then false else even (n - 1);
even 10
let rec
even = \n -> if n == 0 then true else odd (n - 1),
odd = \n -> if n == 0 then false else even (n - 1)
in
even 10
If you define local helpers in plain let and reference each other, you will get unbound-variable
errors. Use let rec for local recursion.
2) List construction and list patterns
- Pattern matching:
x::xsis valid incasepatterns. - Expression construction:
x::xsandCons x xsare equivalent (list literals are also valid).Consuses normal constructor and function call style (Cons head tail).
Equivalent:
x::xs
Cons x xs
3) ADT equality is not implicit
Do not assume custom ADTs automatically implement Eq. For example, comparing Node values with
== can fail with a missing-instance type error.
For small enums and ADTs, write an explicit equality helper:
node_eq = \a b ->
match (a, b) with {
case (A, A) -> true;
case (B, B) -> true;
case _ -> false;
}
Related: avoid checking list emptiness with direct equality like xs == [] in generic code. Prefer
an explicit matcher helper.
4) Ambiguous constructors (for example Empty)
Constructors like Empty can be ambiguous when multiple ADTs define the same constructor name (for
example List.Empty and Tree.Empty).
Disambiguate with an annotation at the binding site:
type Tree = Empty | Node { key: i32, left: Tree, right: Tree };
let
t0: Tree = Empty
in
match t0 with {
case Empty -> 0;
case Node {key, left, right} -> key;
}
5) Reserved identifiers
Avoid bindings that collide with keywords (for example as). Use alternatives like xs1, lefts,
rest1, and similar.
6) Constructor patterns with literals
Some forms like Lit 1 inside nested patterns can fail to parse. Prefer simpler constructor
patterns and do literal checks in expression logic if needed.
Also avoid relying on tuple and list patterns that include numeric literals in one branch (for
example (x::_, 0)); match structurally first, then use an if guard in expression code.
Validation Workflow
Before emitting generated Rex snippets in docs:
- Save the snippet to a temporary
.rexfile. - Run
cargo run -p rex-cli --bin rex_cli -- /tmp/snippet.rex. - If parse or type errors appear, fix and re-run until clean.
For mdBook interactive demos, also run:
cd docs
mdbook build