Language Tooling / Systems / Research Notes
OJaml: An OCaml-Inspired Language Compiled to WebAssembly
A complete browser-native language pipeline: lexical analysis, recursive-descent parsing, Hindley-Milner-style inference, typed standard-library schemes, closure conversion, WebAssembly emission, runtime execution, and Monaco tooling.
Abstract
OJaml is an OCaml-inspired language implemented end to end in TypeScript. The project owns the full compiler pipeline: source text is lexed into tokens, parsed into an expression AST, checked by a Hindley-Milner-style unifier with explicit polymorphic standard-library schemes, lowered to WebAssembly text, compiled to a binary module through WABT, and instantiated directly in the browser. The language supports top-level and local bindings, recursion, top-level and nested modules with local type declarations, abstract and concrete type signatures, value signatures, opened module values/types/constructors, record and algebraic data type declarations with type parameters, value, function, and higher-order function annotations, top-level opens for built-in and user-defined namespaces, sequencing, forward pipelines, pattern matching with tuple, record, list, fixed-length array, set, map, and constructor destructuring, first-class high-arity functions, staged closures, ints, floats, strings, unit, tuples, zero-based tuple projection, structural records, field access, power expressions, polymorphic functions, polymorphic arrays, lists, sets, maps, higher-order collection functions, runtime access checks, print/println output, to_string formatting, diagnostics, completions, token-level hovers, and a reusable Monaco editor package. This paper specifies the syntax, static semantics, runtime representation, compilation strategy, proof obligations, and implementation boundaries needed to reconstruct the current OJaml system.
I
Thesis and Design Contract
OJaml is built around one constraint: the browser demo must be the real language implementation. There is no server compiler and no interpreter shortcut hidden behind the editor. The same source pipeline supports the reusable editor, the route embedded in the website, the command-line interface, the tests, and the generated WebAssembly output.
OJaml keeps the source language narrow enough for the whole compiler to fit in a TypeScript codebase, while covering the mechanisms that distinguish an ML-family compiler from a syntax demo: inference, recursion, closures, high-arity function values, heap allocation, indirect calls, polymorphic containers, and browser-native tooling. Syntax is OCaml-like, values cross WebAssembly function boundaries through a uniform i32 slot, static checks run before emission, and every standard-library function has an explicit type scheme.
The implementation keeps stage boundaries visible. Lexing decides what tokens exist. Parsing decides the tree shape. Checking decides whether names, calls, branches, patterns, and standard-library uses are valid. Code generation decides memory layout and call shape. Runtime execution only runs programs that survived those prior stages.
The main boundary is between the language surface and the representation the machine runs. The source language has ints, floats, tuples, records, functions, arrays, lists, sets, maps, strings, and pattern matching. The emitted WebAssembly mostly sees immediate integers and heap pointers. The type checker records the meaning that the backend erases from the raw i32 signatures.
- The editor, examples, tests, and CLI exercise the same language stages.
- The checker owns static validity; the runtime assumes checked programs.
- The backend targets portable WebAssembly text instead of JavaScript evaluation.
- The current scope is finite: top-level and nested modules, local type declarations, abstract and concrete type signatures, and value signatures are supported, but file imports, functors, exceptions, and garbage collection are not.
II
Surface Language
An OJaml program is a sequence of top-level let declarations, module type declarations, module declarations, top-level open declarations, record type declarations, and algebraic data type declarations with optional type parameters. A let declaration may be recursive, may bind parameters, may annotate those parameters, may carry a value annotation, and may be separated by optional double semicolons. Module declarations contain value bindings, nested modules, record types, and algebraic data types; module members are reached through qualified names such as Scores.total, Scores.Offsets.make, Geometry.point, or Geometry.Named, and open Geometry exposes immediate values, types, and constructors by short name when no local, top-level, or competing opened namespace owns that name. Module type declarations contain abstract type entries, concrete record or variant type manifests, and val signatures. Module ascription checks that an implementation exports every promised type and value at the declared type, and concrete manifests must match the implementation's field or constructor structure. Expressions include primitives, tuples, tuple projection, records, field access, variables, unary and binary operations, sequencing, forward pipelines, conditionals, local lets, local function bindings, local recursive function bindings, function application, anonymous functions, constructor application, and match expressions.
let main =
println "Hello, OJaml!"The surface syntax borrows the OCaml forms that support this compiler's goals without committing to the whole language. Function application is whitespace-based. Parentheses group expressions and represent unit when empty. Sequencing uses expr; expr, requires the left expression to return unit, and returns the right expression's type. Forward pipelines use value |> f and typecheck as f value, so the target must be a one-argument function after any ordinary application on the right has run. Structural records use { field = value; other = value } syntax, record type declarations use type person = { name: string; year: int }, algebraic data type declarations use type status = Pending | Done of int and type 'a option = None | Some of 'a, value annotations use let ada : person = ... or let value : int option = ..., parameter annotations use let describe (person : person) = ..., higher-order annotations use forms such as let apply (f : int -> int) = ..., and field access uses value.field. Comments are block comments with nesting support. Module-style names such as Map.get or Scores.total are lexed as identifiers, and open List or open Scores exposes values by short name; user modules also expose immediate type names and constructors by short name.
open List
open String
let main =
let words = split (concat "hello" " OJaml") " " in
String.length (head words) + List.length wordslet rec fact n =
match n with
| 0 -> 1
| 1 -> 1
| _ -> n * fact (n - 1)
let main =
let x = fact 5 in
if x > 100 then x else 0Every construct in the grammar maps to a direct AST node. That correspondence is an engineering choice: it keeps diagnostics precise, lets hover spans point back to real tokens, and lets later compilation passes switch on explicit node kinds instead of recovering meaning from parser artifacts.
- Lexed token kinds include ints, floats, strings, identifiers, keywords, operators, parentheses, braces, pipes, arrows, equals, separators, and EOF.
- Supported primitive values are int, float, bool, string, and unit; tuple expressions group values by position, zero-based postfix projection reads tuple elements, record type declarations name record shapes, algebraic data type declarations name constructor sets and type parameters, structural records group values by label, and fst/snd remain pair-specific helpers.
- Supported binary operators include int and float arithmetic, right-associative power **, mixed numeric comparisons, equality/inequality, short-circuit boolean conjunction/disjunction, int-only mod, and forward pipeline |>.
- Patterns cover int, float, string, bool, unit, tuple structure, record structure, list structure, fixed-length array structure, set structure, map structure, constructor structure, wildcard, and variable catch-all patterns.
III
AST and Source Spans
The parser emits a purpose-built AST rather than preserving parser tokens as the executable representation. Program nodes contain declarations. Declaration nodes store the binding name, precise binding span, parameter names, parameter spans, body expression, and full declaration span. Module declarations store a module name and the qualified value, type, and nested module declarations inside its struct body. Expression nodes carry a kind-specific payload and a source span.
Program declarations: TopLevelDeclaration[] TopLevelDeclaration Let | Type | Open | Module Declaration kind: Let recursive: boolean name: string nameSpan: SourceSpan params: string[] paramSpans: SourceSpan[] value: Expr span: SourceSpan TypeDeclaration kind: Type name: string params: TypeParameter[] body: RecordType | VariantType OpenDeclaration kind: Open module: string ModuleDeclaration kind: Module name: string declarations: Declaration[] Expr Int | Float | String | Bool | Unit | Tuple | TupleAccess | Record | FieldAccess | Var Unary | Binary | Sequence | If | LetIn | Call | Fun | Match LetIn recursive: boolean name: string value: Expr body: Expr Pattern PInt | PFloat | PString | PBool | PUnit PTuple | PRecord | PArray | PSet | PMap | PConstructor PListNil | PListCons | PWildcard | PVar
Spans are not cosmetic. They are part of the language service contract. Diagnostics use spans to mark errors; editor hovers use spans to identify exactly which token is being described; local binders and function parameters keep separate name spans so a hover over the binder reports the same inferred type as a hover over a later use.
This design keeps binding structure in the AST, so downstream passes do not need to re-tokenize source text to understand binding sites. The checker can annotate the program with symbol and token metadata after unification has resolved type variables.
- Declaration name spans and parameter spans make top-level hovers precise.
- Local let name spans make inferred local values hoverable at the binding site.
- Function parameter spans make anonymous function parameters hoverable.
- Pattern spans make variable catch-all and tuple-destructured bindings available to diagnostics and hovers.
IV
Static Semantics and Type Representation
OJaml uses a Hindley-Milner-style constraint system. Types are primitives, type variables, applications for tuples/records/arrays/lists/sets/maps, nominal variants with type arguments, and function types. Record type declarations add named record shapes to the checker environment; algebraic data type declarations add nominal variant type constructors and constructor bindings. Module-local types and constructors are stored under qualified names; declarations inside the same module receive short-name aliases for sibling and enclosing module types, and open declarations add short-name aliases for immediate exported values, types, and constructors. Value, function parameter, and higher-order function annotations resolve those names and unify the annotated position with the declared shape. Module signature ascription first verifies required abstract type entries against the module's local type declarations, then resolves each val type in that local type namespace and unifies it with the exported member's inferred type. Checking walks the AST, creates fresh type variables where information is not known yet, and unifies constraints as expressions demand relationships between values.
In plain terms, inference means type annotations are not required everywhere. The checker invents placeholders, then replaces or links those placeholders as it learns facts from literals, operators, branches, function calls, annotations, and standard-library signatures. If two facts disagree, such as a branch being both int and string or an annotated record missing a declared field, the program is rejected before code generation.
Type
prim("int" | "float" | "bool" | "string" | "unit")
var(id, instance?, numeric?)
app("array", [elem])
app("list", [elem])
app("set", [elem])
app("tuple", [item0, item1, ...])
app("record", {field: type, ...})
app("map", [key, value])
variant("option", [item])
fn(params[], result)let main =
if true then 1 else "no"Top-level declarations are installed into the global environment before their bodies are checked. A zero-parameter declaration receives a fresh type variable. A parameterized declaration receives a function stub whose parameter and result types are fresh variables. This permits recursive references because the name is available before the body is checked. Local let rec follows the same idea on a smaller scope: the checker inserts the local function name before checking its body, unifies that placeholder with the function type, and rejects recursive local bindings that are not functions.
The checker rejects duplicate top-level bindings, undefined names, arity errors, branch disagreement, sequence expressions whose left side is not unit, pipeline targets that are not one-argument functions, tuple/record/list/array arity, label, or element mismatches in expressions and patterns, non-exhaustive matches without wildcard, variable, structurally exhaustive tuple/record arms, or complete list empty/cons coverage, invalid tuple projection, invalid pair helpers, missing record fields, duplicate record labels, invalid print/println arguments, and main values that cannot be returned directly from the runtime. main may return int, float, bool, or unit; strings and heap values should be printed, converted with to_string, or reduced to one of those result types.
- Pruning follows instantiated type variables until it reaches a concrete representative.
- Occurs checks prevent infinite types such as a = a -> b.
- Freshening copies polymorphic variables so one builtin use cannot constrain another unrelated use.
- Polymorphic functions whose bodies use numeric operators display constrained variables as number when they can be instantiated at either int or float call sites.
- showType formats resolved types for diagnostics, completion details, and hover output.
V
Typed Standard Library
The standard library is part of the type environment, not an untyped escape hatch. Each builtin has a visible signature and a type factory. When the checker creates the builtins map, the factory constructs the type graph for that builtin. When a builtin is referenced, the checker freshens the graph so each call site receives independent variables.
let main =
let names = Map.set (Map.empty ()) "ada" 1815 in
if Map.has names "ada"
then Map.get names "ada"
else 0Polymorphic collections force the signature table to connect every repeated type variable at the call site. Array.make connects its element argument to the returned array element type. Array.append and List.append require both operands and the result to share one element type. Array.reverse and List.reverse return a collection with the same element type as the input. Array.filter and List.filter require predicates over the existing element type and return the same collection element type. Array.exists, Array.for_all, List.exists, and List.for_all use the same element-to-bool predicate relationship and return bool. List.cons connects the head value and tail list. Set.add connects the old set element type, inserted value, and returned set. Map.set connects map key, provided key, map value, provided value, and returned map. Map.get returns exactly the value type stored by the map and accepts exactly the map key type.
let main =
let names = Map.set (Map.empty ()) "ada" 1815 in
Map.get names 1815The editor reuses the same signature table for completion details. That prevents the type checker, hover provider, and autocomplete provider from drifting into contradictory definitions of the standard library.
- print and println are checked through custom call logic: they accept int, float, or string and return unit.
- to_string accepts any value and formats primitives, tuples, records, arrays, lists, sets, maps, and functions for output.
- Tuple postfix projection reads any statically known element by zero-based index; fst and snd remain pair-specific helpers that reject non-pairs.
- Array.append and List.append require matching element types and preserve operand order.
- Array.reverse and List.reverse allocate reversed collections without changing element types.
- Array.filter and List.filter require predicates returning bool and preserve the original element type.
- Array.exists, Array.for_all, List.exists, and List.for_all require bool predicates and short-circuit when the result is known.
- Array.iter and List.iter require callbacks returning unit.
- Array.fold_left and List.fold_left keep accumulator type independent from element type.
- Polymorphic builtins compile to uniform i32 functions at runtime; the checker preserves their static element, key, value, and callback relationships.
VI
Inference Proof Sketch
The checker is not a formal proof assistant, but its structure follows the standard progress path for a typed language: every accepted expression receives a type, and every emitted call has a statically established arity and value representation. The proof obligation is limited by OJaml's uniform WebAssembly ABI: values travel through i32 parameters and results, with compiler-side int/float specialization where polymorphic functions need different concrete representations at different call sites.
Soundness here means the compiler never emits direct WebAssembly for a program with unresolved names, inconsistent branches, impossible call arity, or collection access whose key/value relationship is statically contradictory. Runtime helpers also trap invalid collection access such as out-of-bounds array reads, empty-list head/tail, and missing map keys. This is still not a full safety proof for every pointer operation: garbage collection and recoverable language-level exceptions are outside the current core.
The reconstruction hinge is preservation under unification: once two types are unified, all later pruned references see the same representative. Local hover information stays accurate because a binding span and every later use point at type graph nodes that resolve through the same pruning path.
- Base cases assign primitive types to literals.
- Inductive expression cases add local constraints and unify recursively checked subexpressions.
- Function cases introduce fresh parameter variables and infer the body under the extended environment.
- Match cases unify every pattern with the scrutinee and every arm body with a shared result.
VII
First-Class Functions and Closures
OJaml supports first-class functions through heap-allocated closures and WebAssembly function tables. A top-level function can be called directly when statically known, or wrapped as a closure when passed as a value. Anonymous and local functions become pending lambdas with captured variables recorded from free-variable analysis. A local recursive function stores its own closure pointer in the captured environment when the function body refers to its name. The function-table ABI is generated from the program, so closure calls are not capped at a small fixed arity; returned and staged closures use the same generated call path.
closure pointer p
p + 0 table index
p + 4 captured value 0
p + 8 captured value 1
...
indirect call:
call_indirect(type fn_n)
env = p
arg_1 ... arg_n
table_index = load(p + 0)let make_adder x =
fun y -> x + y
let main =
let add10 = make_adder 10 in
add10 32A closure stores a table index followed by captured values. Indirect calls load the function table index from the closure pointer and pass the closure pointer as an environment parameter. Lambda bodies recover captured values by loading from fixed offsets in that environment.
let main =
let xs = List.cons 3 (List.cons 2 (List.cons 1 (List.empty ()))) in
List.fold_left (fun acc x -> acc + x) 0 xsThis representation keeps direct calls efficient while supporting higher-order collection functions such as map, filter, exists, for_all, iter, and fold_left. The compiler emits the arity-specific WebAssembly function types required by the current program, and the standard library calls callbacks through call_indirect with the matching type.
- Top-level functions receive closure wrappers when they are used as values.
- Pending lambdas are emitted after top-level declarations, with stable function-table indices.
- Indirect-call arities are generated from the current program, so closure calls scale with the highest first-class or staged function arity the source actually uses.
- Captured locals and captured captures are both loaded into the new closure environment.
VIII
Runtime Value Representation
The WebAssembly backend uses i32 as the universal value slot: every OJaml value that crosses a generated WebAssembly function boundary is carried in an i32 parameter or result. That does not mean every source value is an immediate integer. Integers and booleans are immediate i32 values, unit is zero, and floats are boxed f64 heap objects addressed by i32 pointers. Strings, tuples, records, algebraic data type values, arrays, lists, sets, maps, and closures are also heap pointers. WebAssembly function signatures stay uniform, while runtime interpretation depends on the static type chosen before emission.
The backend tradeoff is representation opacity. Uniform i32 values give direct calls, indirect calls, and polymorphic collection helpers the same WebAssembly signature shape. The cost is that WebAssembly itself no longer knows whether an i32 is an immediate integer, a boxed-float pointer, a string pointer, a tuple pointer, a record pointer, a list pointer, a set pointer, or a closure pointer. OJaml relies on the checker and specialization pass to preserve that meaning before emission.
float pointer f f + 0 f64 payload array pointer a a + 0 length a + 4 element 0 a + 8 element 1 tuple pointer t t + 0 arity t + 4 item 0 t + 8 item 1 record pointer r r + 0 field count r + 4 sorted field 0 r + 8 sorted field 1 variant pointer v v + 0 constructor tag v + 4 payload (when present) list pointer l l + 0 head l + 4 tail pointer (0 = empty) set pointer s s + 0 value s + 4 next pointer (0 = empty) map pointer m m + 0 key m + 4 value m + 8 next pointer (0 = empty) closure pointer c c + 0 table index c + 4 captured value 0
The heap begins after static string data. Allocation is bump-pointer allocation: alloc(bytes) returns the current heap pointer and advances it by the requested byte count. There is no garbage collector in the current implementation. Allocated tuples, records, variant values, arrays, cons cells, set entries, map entries, and closures live for the lifetime of the module instance.
Strings are emitted as WebAssembly data segments and represented by their memory offset. Floats are boxed by allocating eight bytes, storing an f64 payload there, and passing the resulting pointer through the same i32 value slot used by every other OJaml value. Float arithmetic and power unbox those pointers to f64 operands, perform the f64 operation, and box float results again. The runtime imports print_i32, print_f64, print_string, string primitives, pow_f64, and to_string support from JavaScript. The compiler chooses which import to call by consulting expression shape metadata derived from checked code.
- Array.make traps negative lengths, and Array.get/Array.set trap null arrays, negative indexes, and indexes greater than or equal to the stored length.
- Array.append allocates a new array and copies left values followed by right values; Array.reverse allocates a new array and copies elements from the end of the source to the front of the result; Array.exists and Array.for_all walk until the predicate determines the final bool.
- Tuple values allocate fixed-size blocks and rely on the checker for arity and element-position consistency; tuple projection and fst/snd lower to fixed slot loads.
- Record values allocate fixed-size blocks with fields sorted by label; field access and record patterns lower to fixed slot loads selected by the checked record type.
- List.empty, Set.empty, and Map.empty are represented by null pointer 0; List.head and List.tail trap on empty lists, List.append copies the left spine while sharing the right list, List.reverse builds a new reversed spine, and List.exists/List.for_all stop as soon as the answer is known.
- Set.add prepends a value only when Set.has cannot find an equal existing value; float sets compare unboxed f64 payloads.
- Map.set prepends a key/value entry, making newer bindings shadow older equal keys.
- Map.get traps if no matching key exists; Map.has remains the non-trapping presence check.
IX
WebAssembly Backend
The compiler emits a complete WebAssembly text module. The module declares the arity-specific function types needed for indirect calls in that program, imports print_i32, print_f64, print_string, string host helpers, pow_f64, and to_string support, exports memory and main, defines a function table, owns a mutable heap global, emits standard-library helpers, emits closure wrappers, emits top-level declarations and int/float specializations, emits pending lambdas, and appends string data segments. User module values are emitted as qualified globals such as Scores.total or Scores.Offsets.make; module-local types and value signatures feed the checker, module-local variants feed the constructor table, opened constructors are resolved to their qualified constructor tags before emission, and modules are a compile-time namespace layer rather than runtime records.
(module (type $fn_1 ...) ... (type $fn_n ...) (import "env" "print_i32" ...) (import "env" "print_f64" ...) (import "env" "print_string" ...) (import "env" "string_concat" ...) (import "env" "pow_f64" ...) (import "env" "to_string" ...) (memory (export "memory") 1) (table N funcref) (global $heap (mut i32) ...) ;; allocator and collection helpers ;; top-level closure wrappers ;; user declarations and int/float specializations ;; pending lambdas ;; string data segments (export "main" (func $main)))
let main = 40 + 2
;; lowers approximately to:
(func $main (result i32)
(i32.add (i32.const 40) (i32.const 2)))Direct calls are emitted when the callee is a known global function. Polymorphic top-level functions may emit concrete int and float variants so a function such as square can be used at both square 9 and square 2.5 without confusing immediate ints with boxed-float pointers. Local function values, captured function values, anonymous functions, returned closures, staged closures, and top-level functions used as values are emitted through closure allocation and call_indirect. The backend generates call_indirect types up to the maximum arity used by those function values, keeping a direct call path for known functions without imposing a practical source-level argument limit on closures.
Expression emission follows the AST. Literals become constants, boxed floats, or string offsets. Tuple expressions allocate a fixed-size block, store the arity, then store each element in order. Tuple projection compiles to a fixed offset load after checking the receiver shape and index. Record expressions allocate the same block shape with fields sorted by label, so access and pattern matching use stable offsets even when source field order varies. Binary operators become i32 or f64 operations depending on checked expression shape. Power is right-associative; int ** int lowers through an integer result helper, while any float operand routes through pow_f64 and returns a boxed float. Pipeline emission reuses normal call emission with the left expression appended as the single argument, so direct calls, opened stdlib calls, closures, returned closures, and int/float specializations stay on the same path. Local lets become blocks that set locals then evaluate the body; local recursive functions allocate a closure that can capture its own pointer. Conditionals and matches become structured WebAssembly if expressions. Function values become closure pointers.
- safe(name) maps source names like Map.get to valid WebAssembly identifiers such as Map_get.
- StringPool interns string literals and emits one null-terminated data segment per distinct value.
- The table contains top-level closure wrappers followed by anonymous lambda functions.
- Indirect-call function types are generated up to the highest closure arity used by the program.
- The backend emits all user-level values as i32, relying on prior static checks and int/float specialization for meaning.
X
Pattern Matching
Pattern matching is implemented for primitive, tuple, record, list, fixed-length array, set, map, constructor, and catch-all patterns. A match expression checks the scrutinee once, then checks every arm under an environment extended by variables bound by the pattern. Arm result types must unify, and the match must contain a wildcard, variable catch-all, a tuple or record pattern whose subpatterns are all exhaustive, complete constructor coverage, or complete list coverage with both [] and a catch-all cons arm. Array, set, and map patterns match exact stored lengths and therefore do not make a match exhaustive without a catch-all.
let classify n =
match n with
| 0 -> "zero"
| 1 -> "one"
| value -> "many"let describe point =
match point with
| (0, 0) -> "origin"
| (x, y) -> String.concat (to_string x) (String.concat "," (to_string y))let rec sum xs =
match xs with
| [] -> 0
| head :: tail -> head + sum taillet main =
let names = Set.add (Set.add (Set.empty ()) "Ada") "Grace" in
let years = Map.set (Map.set (Map.empty ()) "Ada" 1815) "Grace" 1906 in
match (names, years) with
| ({| "Grace"; "Ada" |}, {| "Grace": year; "Ada": 1815 |}) -> year
| _ -> 0type 'a option = None | Some of 'a
type ('ok, 'err) result = Ok of 'ok | Error of 'err
let score maybe =
match maybe with
| None -> 0
| Some value -> value
let label result =
match result with
| Ok name -> String.length name
| Error code -> codeThe exhaustiveness rule is conservative. The checker does not attempt full finite-domain analysis for bool, literals, or every possible array length. Instead, it requires a catch-all pattern, a tuple or record pattern whose nested patterns are all catch-alls, or the standard list split of [] plus a catch-all cons arm. Fixed-length array patterns are useful for destructuring known shapes, but they do not make a match exhaustive by themselves.
The backend stores the scrutinee in a scratch local, then emits a chain of WebAssembly conditionals. Wildcard, unit, and variable patterns can immediately produce their body. Literal patterns compare the scrutinee against the literal representation. Tuple patterns test the tuple arity, recursively test nested element patterns, and bind variables from fixed element offsets. Record patterns test the field count, recursively test field patterns in sorted-label order, and bind variables from fixed field offsets. Array patterns test the array pointer and length, then recurse through fixed element offsets. List patterns test the pointer for null or non-null, then bind head and tail from the cons cell before evaluating the arm body. Set and map patterns walk the linked entries in stored order, testing each item or key/value pair and requiring the pattern length to consume the whole collection. Constructor patterns test the constructor tag and bind the payload slot when the constructor carries one.
- PInt, PFloat, PString, PBool, and PUnit unify the scrutinee with the matching primitive type.
- PTuple unifies the scrutinee with a tuple type of the same arity and checks each element pattern against the corresponding element type.
- PRecord unifies the scrutinee with a record type containing the same labels and checks each field pattern against the matching field type.
- PArray unifies the scrutinee with an array type, checks every element pattern against the shared element type, and matches only arrays of the same length.
- PListNil unifies the scrutinee with a list type and matches only the empty list; PListCons unifies the head with the element type and the tail with the same list type.
- PSet unifies the scrutinee with a set type and checks each stored-entry pattern against the set element type.
- PMap unifies the scrutinee with a map type and checks each key pattern against the key type and each value pattern against the value type.
- PConstructor unifies the scrutinee with the constructor's nominal variant type and checks any payload pattern against the declared payload type.
- PWildcard accepts any scrutinee type and binds nothing.
- PVar accepts any scrutinee type and binds the variable to that type in the arm body.
- Missing catch-all arms are rejected before code generation.
XI
Tradeoffs and Current Boundaries
OJaml chooses a compact compiler over a complete OCaml clone. The current surface demonstrates inference, top-level and nested modules with local type declarations, abstract type signatures, concrete record and variant type manifests, val signatures, top-level and local function recursion, high-arity closures, staged closures, sequencing, forward pipelines, tuples, tuple projection, record and algebraic data type declarations with type parameters, value annotations, function parameter annotations, higher-order function type annotations, top-level opens for built-in and user-defined namespaces, structural records, field access, tuple/record/list/array/set/map/constructor destructuring, collections, pattern matching, WebAssembly emission, and editor tooling, but it does not yet include file imports, functors, exceptions, or a garbage collector.
The runtime makes a similar bargain. Bump allocation and linked heap layouts keep allocation code short and predictable, but allocated data lives for the lifetime of the module instance. Collection helpers now trap common invalid accesses instead of reading arbitrary memory, but those traps are not recoverable OJaml exceptions. A production ML runtime would need garbage collection, richer failure values, and a way to recover from or report runtime faults inside the language.
- Static typing protects source-level meaning, while the backend keeps a uniform i32 representation.
- Direct calls stay simple, while closure values use generated arity-specific function-table indirection only when needed.
- Polymorphic builtins are typed precisely, but their runtime helpers operate on uniform pointers and integers.
- The editor tooling benefits from source spans carried through the parser and checker.
XII
Monaco Tooling and Language Service
The Monaco integration turns the compiler into an interactive language workbench. The editor registers an OJaml language ID, language configuration, Monarch tokenizer, dark and light themes, completion provider, hover provider, signature help provider, and diagnostic marker producer.
hover(offset) | +-- parse + check succeeds? | | | +-- checked token covers offset -> show inferred detail | | | +-- otherwise -> lexical fallback | +-- parse/check fails -> lexical fallback
let main =
let names = Map.set (Map.empty ()) "ada" 1815 in
Map.get names "ada"
hover Map.get:
Map.get : (string, int) map -> string -> intDiagnostics call parse and check on the current source. On OJamlError, the provider translates byte offsets to Monaco line/column ranges. Completion items include keywords, snippets, top-level symbols, module members, and standard-library functions. Module-qualified autocomplete is context-aware: after Array., Float., List., Map., Set., String., or a user-defined module prefix, it offers value and constructor members from that namespace rather than inserting a second module prefix.
Hover data is checker-first and lexical-second. If the program parses and type checks, the hover provider uses checked token metadata so identifiers and builtin calls show inferred or instantiated types. If no checked token applies, lexical fallback still describes keywords, literals, operators, delimiters, declaration separators, and unknown identifiers.
- Completion details come from the same stdlib signature table used by the checker.
- Signature help currently focuses on print and println because they accept int, float, or string.
- Token-level checked hovers cover locals, params, top-level declarations, literals, and builtin calls.
- Lexical hovers identify non-typed tokens as keyword, operator, delimiter, separator, literal, or identifier.
XIII
Validation Strategy
OJaml is validated with a Node test suite that exercises parsing, emitted WebAssembly text, runtime execution, diagnostics, polymorphic functions with int/float specialization, built-in and user-defined module opens, module type declarations, abstract signature type entries, concrete record and variant signature manifests, val signature ascription, opened module-local types and constructors, nested modules, module-local record and algebraic data types, ambiguous-name rejection, expression sequencing and unit-left diagnostics, forward pipelines through direct functions, stdlib functions, returned closures, and specialization, exact editor-example output transcripts, power precedence and associativity, runtime access traps, tuple and record type checking, record and algebraic data type declarations with type parameters, value annotations, function parameter annotations, higher-order function annotations, tuple projection, pair helpers, tuple, record, list, array, set, map, and constructor pattern matching, tuple and record formatting, polymorphic arrays, polymorphic lists, polymorphic sets, polymorphic maps, polymorphic ADT constructor instantiation, pattern matching, top-level and local recursion, first-class high-arity functions, staged closures, collection append/reverse/filter/exists/for_all/map/iter/fold behavior, to_string formatting, print/println behavior, and editor hover metadata.
let main =
let m = Map.set (Map.empty ()) "one" 1 in
let m = Map.set m "two" "nope" in
Map.get m "one"The strongest tests are negative tests for the checker and runtime integration tests for language features. Negative tests prove the checker rejects invalid programs before emission. Runtime tests prove accepted programs still execute correctly after lowering to WebAssembly.
The site build is also part of validation because OJaml is consumed as a submodule-backed editor package. A successful frontend build confirms the paper content, route import, and package integration still compile together.
- Feature tests cover each supported syntax and standard-library family.
- Compiler tests inspect generated WAT for scalar programs.
- Runtime tests execute WASM and compare main result plus captured output transcripts.
- Runtime safety tests assert traps for negative array lengths, out-of-bounds array access, empty-list head/tail, and missing map keys.
- Collection tests cover append and reverse order, empty operands, singleton values, source preservation, predicate short-circuiting, polymorphic element values, and mismatch diagnostics for arrays and lists.
- Specialization tests cover direct and higher-order polymorphic function calls across int and float call sites, including power-based helpers.
- Module tests cover parsing, qualified member calls, user-defined opens, module type declarations, abstract type signatures, concrete record and variant signature manifests, polymorphic val signature ascription, opened module-local types and constructors, local shadowing, sibling references, closures that capture module sibling values, duplicate diagnostics, unknown opens, missing signature members, mismatched signature types, and nested-module behavior.
- High-arity tests cover first-class function values, returned and staged closures, local recursive closures, mixed heap/immediate arguments, scratch-local growth past the old fixed pool, generated indirect-call types, and editor-example output.
- Local recursion tests cover local function syntax, self-capture, captured outer locals, non-function rejection, editor examples, and hover strings.
- Tuple tests cover parsing, indexed projection, fst/snd helpers, nested formatting, tuple pattern destructuring, collection nesting, structural type mismatches, direct-main rejection, editor examples, and hover strings.
- Record tests cover parsing, field access, sorted-label formatting, collection nesting, record pattern destructuring, closure captures, missing-field diagnostics, duplicate-label diagnostics, direct-main rejection, editor examples, and hover strings.
- Algebraic data type tests cover nullary and payload constructors, polymorphic option/result-style declarations, generic record aliases, constructor exhaustiveness, parameter arity diagnostics, mismatched payload diagnostics, editor examples, and hover strings.
- Array pattern tests cover parsing, empty and fixed-length patterns, nested element patterns, closure-bound matches, type mismatches, conservative exhaustiveness, editor examples, and hover strings.
- List pattern tests cover [], right-associative cons patterns, recursive destructuring, closure captures, conservative exhaustiveness, diagnostics, editor examples, and hover strings.
- Set tests cover empty sets, persistence, duplicate suppression, float equality, nested formatting, membership diagnostics, and hover strings.
- Editor tests assert diagnostics and hover strings for inferred local and stdlib types.
XIV
Implementation Correspondence
The paper maps directly onto the repository. The language core is not distributed across hidden build steps: each stage has a focused TypeScript module, and the public editor route composes those modules rather than replacing them. A reconstruction should preserve this correspondence so paper claims can be checked against concrete files.
src/lexer.ts tokens, nested comments, string escapes src/parser.ts recursive-descent parser and source spans src/ast.ts Program, Declaration, Expr, Pattern types src/check.ts type graph, unification, stdlib schemes, hover tokens src/compiler.ts WAT emission, heap layouts, closures, stdlib runtime src/runtime.ts WABT conversion, imports, execution result src/monacoOJaml.ts diagnostics, completions, hovers, signature help tests/*.test.ts positive runtime tests and negative checker tests
The most important cross-file invariant is name agreement. Standard-library names are parsed as identifiers, typed by check.ts, assigned arities and return shapes by compiler.ts, emitted as WebAssembly-safe names by safe(name), and surfaced to Monaco through the shared signature list. If one stage adds a builtin without the others, the language becomes inconsistent.
The second cross-file invariant is representation agreement. The checker distinguishes int, float, bool, string, unit, tuples, records, arrays, lists, sets, maps, and functions. The emitter erases those distinctions to i32 only after type checking. Runtime helpers then interpret the i32 according to the static type that selected the helper.
- Extending modules further requires file imports, functors, and richer namespace export rules beyond the current compile-time module surface.
- Adding richer collection exhaustiveness requires rules that describe exact-length arrays, stored-order sets, and stored-order maps without pretending those forms cover every possible value.
- Adding garbage collection requires replacing the monotonic allocator without changing the checker-facing value model.