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.
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.
.rexd files use Rex-like syntax for familiarity but describe types, not executable code.
Uppercase names define reusable types:
Headers = {string | [string]}
HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"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
}
// Mutable — the program can assign to properties
mut res = {
status: number
headers: Headers
body: string
}
// Simple typed globals
config: unknown
secrets: {string}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 immediately above a declaration are extracted as hover documentation:
// Client IP address from the connecting socket or X-Forwarded-For header
ip: string| 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 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 onunknownproducesunknown.undefined— no value. The result of missing properties, failed comparisons, etc. Navigation onundefinedproducesundefined.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 unknown → unknown)
cookies.session // unknown | undefined (map lookup, key may not exist)
when cookies.session do
// cookies.session: unknown (narrowed — undefined removed, value exists)
when number(cookies.session) do
// cookies.session: number (narrowed further)
cookies.session + 1 // valid
end
endGlobals 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.
.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.
The type checker walks the program top-to-bottom, inferring a type for every expression.
| 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} |
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)| 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 |
Comparisons return the left-hand value on success, undefined on failure:
x > 10 // type: typeof(x) | undefined
a == b // type: typeof(a) | undefinedThis means:
score = x > 10 // score: number | undefined (if x: number)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)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) | undefinedLoops:
for x in items do body end // type: typeof(body) (last iteration)
while cond do body end // type: typeof(body) | undefinedComprehensions 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)}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: undefinedFor 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 (narrowed — undefined removed)
endWhen 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}.a → number
// Right branch: {boolean}.a → boolean | undefined (map allows any key)
// Combined: number | boolean | undefinedThis 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 | undefinedAn 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.
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 caseIf 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 (narrowed — user is defined)
endThis 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.
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
endThe 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)
endComparisons narrow the type:
when req.method == "GET" do
// req.method: "GET" (narrowed from the union)
endThe type checker produces warnings and errors based on inferred types.
| 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' |
| 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) |
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
elsebranch handles unknown values)
There is no any escape hatch. unknown requires narrowing before use — the type checker ensures all operations have compatible types.
- Developer creates
http.rexdin the project root describing the HTTP domain - LSP finds and parses the
.rexdfile on startup - When a
.rexfile is opened, the LSP:- Parses the file via the rowan CST parser
- Seeds the type environment from the
.rexdglobals - Walks the CST, inferring types for each expression
- Reports diagnostics (errors, warnings)
- Provides completions, hover, go-to-definition from the inferred types
- On each edit, the LSP incrementally re-parses and re-checks
- No type annotations appear in
.rexfiles — everything is inferred