Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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, main is 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 main and 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 Bar imports 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).
  • import foo.bar (*) imports every exported name into local unqualified scope.
  • import foo.bar (x, y as z) imports selected exported names; y is bound locally as z.
  • 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: Boxed can be both a type name and a constructor value.
  • Module alias imports and clause imports are mutually exclusive in one import declaration.
  • Only pub names 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.bar resolves to foo/bar.rex.
  • Local module paths resolve relative to the importing file.
  • Leading super path segments walk up directories (for example super.core.calc).

Lexical Structure

Whitespace and Comments

  • Whitespace, including newlines, is generally insignificant and indentation has no syntactic meaning. Top-level type, fn, and declare fn declarations, 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 Integral and default to i32 when 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 rec are 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 a ADT (Empty/Cons) in the prelude.
  • Cons expressions use :: (for example x::xs), equivalent to Cons x xs.
  • Cons is 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 to Cons 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:

  • bool
  • i32 (default integer-literal fallback type)
  • f32 (float literal type)
  • string
  • uuid
  • datetime

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 types
  • Eq / Ord
  • Functor / Applicative / Monad for List, Array, Option, Result
  • Foldable, Filterable, Sequence
  • multi-parameter Indexable t a with 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.