Skip to content

Instantly share code, notes, and snippets.

@creationix
Last active March 30, 2026 14:55
Show Gist options
  • Select an option

  • Save creationix/a2dc88dd963b2742592959440f23ba33 to your computer and use it in GitHub Desktop.

Select an option

Save creationix/a2dc88dd963b2742592959440f23ba33 to your computer and use it in GitHub Desktop.

Rex Type System

Rex has no user-space type annotations. Types are inferred from domain interface files (.rexd), literals, operators, and type predicates. The type system exists purely for tooling — the compiler and interpreter are untyped.


Domain Interface Files (.rexd)

A .rexd file declares the types of globals, functions, and type aliases available to Rex programs in a given project. The LSP searches upward from the open file for *.rexd files.

Syntax

.rexd files use Rex-like syntax for familiarity but describe types, not executable code.

Type aliases

Uppercase names define reusable types:

Headers = {string | [string]}
HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"

Globals

Lowercase names declare values available to Rex programs:

// Read-only (default)
req = {
  method: HttpMethod
  path: string | [string]
  headers: Headers
  query: {string | [string]}
  cookies: {string}
  ip: string
  body: string
}

// Mutablethe program can assign to properties
mut res = {
  status: number
  headers: Headers
  body: string
}

// Simple typed globals
config: unknown
secrets: {string}

Functions

Dot-path names with typed arguments and optional return type:

log.info(message: unknown)
log.warning(message: unknown)
json.parse(text: string): unknown
json.stringify(value: unknown): string
res.rewrite(url: string): never
res.redirect(url: string, status: number): never

Comments as documentation

// comments immediately above a declaration are extracted as hover documentation:

// Client IP address from the connecting socket or X-Forwarded-For header
ip: string

Type syntax

Syntax Meaning
string String value
number Any numeric value (integer or decimal)
integer Integer only
boolean true or false
null The null value
unknown Some defined value of unknown type — must narrow (via number(), string(), etc.) before use in operations. Does NOT include undefined. Use unknown | undefined for "might be absent".
never Function does not return (throws or diverges)
"GET" String literal type — only this exact string
[T] Array of T
{key: T, ...} Object with known fields
{T} Map with string keys and value type T
T | U Union — value can be T or U
Name Reference to a type alias

unknown vs undefined

unknown and undefined are distinct:

  • unknown — a value exists, but we don't know its type. Must narrow with type predicates before using in operations. Navigation on unknown produces unknown.
  • undefined — no value. The result of missing properties, failed comparisons, etc. Navigation on undefined produces undefined.
  • unknown | undefined — might be a value, might be absent. Common for map lookups and optional domain fields.
config: unknown                      // definitely a value, type unknown
config.timeout                       // unknown (navigation on unknownunknown)

cookies.session                      // unknown | undefined (map lookup, key may not exist)

when cookies.session do
  // cookies.session: unknown (narrowedundefined removed, value exists)
  when number(cookies.session) do
    // cookies.session: number (narrowed further)
    cookies.session + 1              // valid
  end
end

Mutability

Globals are read-only by default. Use mut to allow property writes:

mut res = { status: number, headers: Headers, body: string }

Inside Rex code, res.status = 404 is valid. Without mut, the LSP reports an error on write attempts.

No short codes

.rexd files describe the developer-facing API. The names match what the programmer writes in Rex code (req.headers, res.status). The compiler maps these to internal short ref codes ('H, 'S) via a separate host-provided mapping. The developer never sees or writes short codes.


Type Inference

The type checker walks the program top-to-bottom, inferring a type for every expression.

Literals

Expression Type
42 integer
3.14, 314e-2 number
"hello" string
true, false boolean
null null
undefined undefined
[1, 2, 3] [integer]
{a: 1, b: "x"} {a: integer, b: string}

Variables

Assignment creates a variable with the type of the right-hand side:

x = 42          // x: integer
name = "Ada"    // name: string
items = [1 2 3] // items: [integer]

Compound assignment preserves type:

x = 0       // x: integer
x += 1      // still integer (integer + integer = integer)

Arithmetic operators

Expression Type
integer + integer integer
number + number number
string + string string (concatenation)
number + string error: cannot add number and string
a - b, a * b, a / b, a % b number (or integer if both integer, except / which is always number)
-a same as a

Comparison operators

Comparisons return the left-hand value on success, undefined on failure:

x > 10    // type: typeof(x) | undefined
a == b    // type: typeof(a) | undefined

This means:

score = x > 10    // score: number | undefined  (if x: number)

Logical operators

Rex uses existence-based logic, not boolean logic:

Expression Type Semantics
a or b typeof(a) | typeof(b) First defined value
a and b typeof(b) | undefined Second value if first is defined
a nor b typeof(b) | undefined Second value if first is undefined

The or pattern is commonly used for defaults:

max = max or 100    // max: number (if max: number | undefined from domain)

Control flow

When / Unless:

when cond do body end           // type: typeof(body) | undefined
when cond do a else b end       // type: typeof(a) | typeof(b)
unless cond do body end         // type: typeof(body) | undefined

Loops:

for x in items do body end      // type: typeof(body) (last iteration)
while cond do body end          // type: typeof(body) | undefined

Comprehensions produce arrays:

[x * 2 for x in items]         // type: [number] (if items: [number])
[self in 1..10]                 // type: [integer]
{k: v for k, v in obj}         // type: {typeof(v)}

Property access

Accessing a property on a typed value narrows by lookup:

req.method        // HttpMethod"GET" | "POST" | ...
req.headers       // Headers → {string | [string]}
req.headers.host  // string | [string] (from map value type)

For objects with known fields, accessing a known field returns its type. Accessing an unknown field is an error (but still types as undefined):

point = {x: 1, y: 2}
point.x           // integer
point.z           // error: unknown property 'z' on {x: integer, y: integer}
                  // type: undefined

For maps ({V}), any string key is valid but the key might not exist, so the result is V | undefined:

cookies = req.cookies    // {string}
cookies.session          // string | undefined (key may not exist)

when cookies.session do
  // cookies.session: string (narrowedundefined removed)
end

Property access on unions

When a type is a union, property access is resolved on each branch independently and the results are unioned:

// Given: value: {a: number, b: string} | {boolean}
value.a
  // Left branch:  {a: number, b: string}.anumber
  // Right branch: {boolean}.aboolean | undefined  (map allows any key)
  // Combined: number | boolean | undefined

This means a union of a known-field object and a map is useful: known fields are accessed precisely, but the map branch provides a fallback for arbitrary keys.

// A config that has known fields but also allows extensions
Config = {timeout: number, retries: integer} | {unknown}

config.timeout    // number | unknown | undefined
config.custom     // error on left branch (unknown field), unknown | undefined on right
                  // combined: unknown | undefined

An object with known fields unioned with a map does NOT suppress the error on unknown fields from the object branch — the error is still reported as a diagnostic, but the type includes the map branch's result so the program is valid.

Navigation on undefined

Accessing a property on undefined is not an error — it simply produces undefined. This means all property chains are implicitly optional, similar to ?. in JavaScript:

config.routing.timeout    // if config: unknown, this is unknown
                          // if config: {routing: {timeout: number}}, this is number
                          // if config: undefined at runtime, this is undefined
                          // no runtime error in any case

If a type includes undefined in a union, the result of navigation adds undefined to the property type:

user = users.0            // user: {name: string} | undefined  (array index)
user.name                 // string | undefined  (undefined propagates)

when user do
  user.name               // string  (narroweduser is defined)
end

This means the type checker never reports a navigation chain as an error. Unknown properties on concrete types produce a warning and type undefined, but the program is still valid.

Type narrowing

Via type predicates

Rex's type predicates (number(), string(), object(), array(), boolean()) act as type guards:

when number(value) do
  // value: number (narrowed from whatever it was)
  value + 1    // valid
end

when string(value) do
  // value: string
  value + " suffix"    // valid
end

Via existence

The when construct narrows undefined out of the type:

name = req.query.name    // string | [string] | undefined (map lookup)

when name do
  // name: string | [string] (undefined removed)
end

Via comparison

Comparisons narrow the type:

when req.method == "GET" do
  // req.method: "GET" (narrowed from the union)
end

Diagnostics

The type checker produces warnings and errors based on inferred types.

Errors (prevent correct execution)

Check Example Message
Type mismatch in operator "hello" - 1 Cannot subtract from string
Wrong argument type json.parse(42) Expected string for argument 'text' of json.parse, got number
Wrong argument count json.parse(a, b) json.parse expects 1 argument, got 2
Missing required field {x: 1} where {x: number, y: number} expected Missing required field 'y'
Field type mismatch {status: "ok"} where {status: number} expected Field 'status' has type string, expected number
Assign to read-only req.method = "POST" Cannot assign to read-only property 'method' on 'req'

Warnings (likely mistakes)

Check Example Message
Unknown property req.headrs Unknown property 'headrs'. Did you mean 'headers'?
Unused variable x = 1 (never read) Variable 'x' is assigned but never used
Unreachable code break; x = 1 Unreachable code after break
Extra object field {x: 1, y: 2, z: 3} where {x, y} expected Unknown field 'z' (structural subtyping allows extras)

Not checked

The type system intentionally does not check:

  • Arithmetic overflow (numbers are arbitrary precision in Rex)
  • Array bounds (out of bound reads are undefined)
  • Exhaustiveness of literal unions (the else branch handles unknown values)

There is no any escape hatch. unknown requires narrowing before use — the type checker ensures all operations have compatible types.


How it works together

  1. Developer creates http.rexd in the project root describing the HTTP domain
  2. LSP finds and parses the .rexd file on startup
  3. When a .rex file is opened, the LSP:
    • Parses the file via the rowan CST parser
    • Seeds the type environment from the .rexd globals
    • Walks the CST, inferring types for each expression
    • Reports diagnostics (errors, warnings)
    • Provides completions, hover, go-to-definition from the inferred types
  4. On each edit, the LSP incrementally re-parses and re-checks
  5. No type annotations appear in .rex files — everything is inferred
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment