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).