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 .rex files. Imports are semicolon-terminated top-level declarations.
Module files are declaration-only: they do not have a top-level expression result. To evaluate an
expression, run a source as a program entry point.
Supported forms:
import foo.bar as Bar;
import foo.bar (*);
import foo.bar (x, y as z);
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:
foo.barresolves tofoo/bar.rex.- Local module paths resolve relative to the importing file.
- Leading
superpath segments walk up directories (for examplesuper.core.calc).
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.
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.
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)stringuuiddatetime
Function Types
Functions are right-associative: a -> b -> c means a -> (b -> c).
Tuples, Lists, Arrays, Dicts
- Tuple type:
(a, b, c) - List type:
List a(prelude) - Array type:
Array a(prelude) - Dict type:
Dict a(prelude; key type is a symbol/field label at runtime) - 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
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 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,Array,Option,ResultFoldable,Filterable,Sequence- multi-parameter
Indexable t awith instances for lists/arrays
Example: Functor across different container types:
( map ((*) 2) [1, 2, 3]
, map ((+) 1) (Some 41)
, map ((*) 2) (Ok 21)
)
Example: Indexable:
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.