Jagger PapersTechnical notes & papers
Jagger Papers

Language Tooling / Systems / Research Notes

OJaml: An OCaml-Inspired Language Compiled to WebAssembly

An OCaml-inspired language with type inference, closures, and WebAssembly execution in the browser.

OJamlTypeScriptReactViteWebAssemblyWABTType InferenceHindley-MilnerCompilerClosuresRecursive Descent ParserMonacoCLI

Abstract

OJaml is an OCaml-inspired language and browser playground that combines local WebAssembly execution with compiler-backed editor feedback. A TypeScript compiler performs inference and static checking before lowering programs through a uniform i32 ABI. Heap closures and program-specific indirect-call types support first-class functions, while specialization preserves integer and floating-point behavior across the shared representation. Source spans and checked metadata supply diagnostics, completions, and hovers from the same implementation used for execution. The result is a browser-hosted language with a compact runtime; garbage collection, recoverable exceptions, file imports, and functors remain outside its scope.

OJaml editor. Source is parsed, type checked, compiled to WebAssembly text, converted to a binary module, and executed locally in the browser.

A Language in the Browser

OJaml is an OCaml-inspired language with a TypeScript compiler and browser playground. It compiles directly to WebAssembly and executes locally. The browser editor, CLI, and tests all use the same implementation, including type inference, pattern matching, and first-class functions.

  1. 01Source
  2. 02Parse
  3. 03Infer types
  4. 04Emit WASM
  5. 05Execute
OJaml at a glance. Source is parsed, checked, and emitted as WebAssembly before local execution.
SourceLexerTokensParserASTCheckerTyped metadataWAT emitterWebAssemblymain()token/span metadatachecked symbols, tokens, main typeWABT conversionbrowser instantiate and execute
Whole-system pipeline. The checker consumes the parser's AST as its semantic input. Token and span metadata from lexing travels on the dashed path for diagnostics, hovers, and source ranges.

The backend uses a uniform i32 ABI for both immediate values and heap pointers. That keeps generated signatures consistent across direct calls, closures, and collection helpers, but makes the checker responsible for distinctions that WebAssembly no longer sees. Integer and floating-point specializations preserve those distinctions where polymorphic code needs different operations.

The editor depends on the compiler too. Binding spans, inferred types, and library signatures survive checking so diagnostics and hovers can describe the same program that will execute. Keeping that information in the language implementation avoids maintaining a second, approximate model just for the playground.

The Surface Language

OJaml keeps OCaml-style application, let bindings, recursion, and matching. The example below gives a compact view of the syntax; main supplies the result exported by the generated module.

let 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 0
Core expression tour. Top-level recursion, match, local let, arithmetic, comparison, and if/then/else all compile through the same expression checker.

The supported module system goes beyond qualified names. Nested modules and opens organize namespaces, while module signatures can contain abstract types, concrete record or variant manifests, and value declarations. Ascription checks both the promised value types and the structure of concrete type declarations. File imports and functors remain outside that scope.

These choices keep namespaces primarily in the compiler. Opened names resolve before emission, module-local constructors receive qualified tags, and module values become qualified globals. The backend does not need runtime module objects. The syntax remains useful for organizing programs without expanding the runtime representation around every namespace.

The Syntax Tree and Source Spans

The AST retains expression spans and separate binder spans. That distinction matters to the editor: a function expression’s range is too broad for a diagnostic or hover aimed at one parameter. Later passes attach their results to the original binding and use sites rather than reconstructing locations after checking.

Checked token metadata can then associate a declaration and its references with resolved types. A mismatch retains the offending expression’s range, while a parameter hover retains the parameter’s own range. The AST is therefore also the basis for the compiler’s editor-facing output, not just an input to code generation.

Type Inference

OJaml’s inference uses fresh type variables and unification, with later references following the pruned representative. The editor relies on that behavior: a type refined elsewhere in a function must also appear refined at the declaration’s hover site. Copying a partial type too early would leave correct checking paired with stale feedback.

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)
Type constructors. Heap-backed compound values are not erased during checking; tuple positions, record labels, collection elements, map keys, map values, and variant type arguments remain visible to unification.

Recursive bindings enter the environment provisionally before their bodies are checked. Constraints from parameters, calls, and results refine that entry, and annotations feed the same process. Match arms are checked in environments extended by their pattern bindings, with their result types required to unify.

Emission starts only after names, call arities, branch types, and collection relationships have been checked. That establishes the types the backend uses to choose representations and helpers. It is a limited static contract: runtime bounds checks and missing-key traps remain necessary, and the implementation does not claim a complete safety proof for heap operations.

The Standard Library

Standard-library schemes instantiate fresh variables at each use while preserving repeated variables within that use. Map.set, for example, connects the map’s key and value types to the inserted pair and the returned map. Map.get uses those same relationships for its key argument and result. Fresh instantiation keeps separate maps independent without letting one map change its value type across calls.

Higher-order collection schemes also constrain callbacks to the collection’s element and result types. The compiler must carry that information through callback emission, where the source-level scheme ultimately meets an arity-specific indirect call.

Completions and hover details reuse the signature table. A library addition therefore has to agree with checking and emitted helpers as well as editor presentation. The full signatures belong in reference documentation; the design concern here is keeping those consumers consistent.

Runtime Values and Memory

Every generated function boundary carries values in i32 slots. Integers and booleans are immediate; floats are boxed f64 values addressed by pointers. Strings, collections, records, variants, and closures are pointers too. WebAssembly signatures consequently cannot distinguish a list pointer from an integer: checked source types select the loads and helpers before those distinctions are erased.

Records use fields sorted by label so construction, access, and matching agree on offsets even when source order differs. Lists and collection entries use linked heap layouts. These representations keep helpers compact, with layout details shared by allocation and the operations that inspect them.

The heap begins after static string data and uses bump allocation. There is no garbage collector, so allocated objects live for the module instance’s lifetime. That is manageable for short playground runs, but accumulated allocation limits longer-lived programs. The uniform ABI simplifies calls without solving memory reclamation.

Pattern Matching

Matching lowers against those same heap layouts. The emitter stores the scrutinee once in a scratch local and generates structured conditionals. Tuple patterns check arity and element offsets; record patterns use sorted field offsets; constructors check tags before loading payloads. Allocation and matching must agree on those representations.

Exhaustiveness checking is deliberately conservative. A catch-all is sufficient; complete constructor coverage and the empty-list/cons split are supported too. Fixed-length array, set, and map patterns do not establish coverage on their own. The checker rejects coverage it cannot establish rather than implementing a general finite-domain analysis.

Set and map patterns also follow stored entry order and consume an exact length. Their matching behavior reflects the linked runtime layout, an important distinction from treating a map pattern as an unordered membership test. The documentation contains the complete pattern rules.

Closures

Known functions retain a direct-call path. When passed as values, top-level functions can be wrapped as closures, while anonymous and local functions capture the variables identified by free-variable analysis. The returned adder below exercises that environment path with one captured offset.

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)
Closure layout. Every closure is both an environment pointer and the source of its callable table entry.

A heap closure stores a function-table index followed by captures. Indirect calls load the index and pass the closure pointer as an environment argument; the body retrieves captures at fixed offsets. A locally recursive function can include its own closure pointer in that environment.

Indirect-call types are generated for the arities actually present in the program. Higher-order collection helpers use the matching call_indirect type, and returned or staged closures use the same convention. This avoids a small fixed-arity ceiling without forcing statically known calls through the closure path.

let make_adder offset =
  fun value -> offset + value

let main =
  let add_seven = make_adder 7 in
  add_seven 5
A Captured Offset. main returns 12. The returned function keeps offset equal to 7 after make_adder has returned.

The WebAssembly Backend

Emission combines those decisions into a WAT module: direct definitions, closure wrappers, program-specific indirect-call types, runtime helpers, and static data. Qualified module names have already been resolved, and checked expression shapes select integer operations, float operations, or host calls.

Polymorphic top-level functions can receive integer and floating-point specializations. A function used at both an integer and a float call site cannot blindly reuse operations over the uniform i32 slot: one operand is immediate and the other addresses boxed data. Specialization preserves that distinction after checking.

WABT converts the emitted text to a binary module. Instantiation provides output and supporting host functions, and the runtime reads main’s result. The editor’s execution route therefore uses the generated module, with the same lowering exercised by CLI and runtime tests.

Compiler Feedback in the Editor

Monaco consumes checked token metadata for inferred and instantiated types. A Map.get hover can show the types of the particular call rather than only the library’s generic scheme. Diagnostics translate compiler offsets to editor ranges, retaining the binder precision established in the AST.

hover(offset)
  |
  +-- parse + check succeeds?
  |      |
  |      +-- checked token covers offset -> show inferred detail
  |      |
  |      +-- otherwise -> lexical fallback
  |
  +-- parse/check fails -> lexical fallback
Hover decision tree. Typed hover information is preferred, but every meaningful token can still explain itself.

Incomplete or rejected programs cannot always supply checked metadata. Lexical fallback still describes recognizable tokens, while completion uses shared signatures and namespace context. After a module prefix, the provider offers that module’s members without inserting the prefix again.

The fallback is narrower than successful checking, but keeps feedback available during editing. Shared compiler metadata and signature tables reduce drift between what the editor suggests and what the language accepts; they do not turn a failed check into a reliable inferred type for every expression.

Validation and Limits

Validation crosses both sides of the checker/backend boundary. Negative cases exercise contradictory collection types, unresolved names, and invalid calls; execution cases exercise specialization, recursive and high-arity closures, patterns, and module resolution. Editor checks additionally compare inferred metadata and diagnostic ranges with source locations.

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
Source map. The implementation is stage-shaped, so every concept in the paper has a concrete module.

Checked programs can still trap on out-of-bounds reads, empty-list access, or absent map keys. Those failures are runtime traps rather than recoverable OJaml exceptions. File imports, functors, exceptions, and garbage collection remain outside the current implementation.

OJaml’s scope is a browser-hosted language whose compiler also supplies its editor feedback. The uniform ABI makes that runtime small, while checking and specialization preserve the distinctions it erases. Extending the language means preserving that agreement across types, layouts, calls, and source metadata.