| name | ocaml2moonbit-migration |
| description | Guide for migrating OCaml projects, libraries, modules, and test suites to idiomatic MoonBit. Use when translating OCaml code to MoonBit, planning a large OCaml-to-MoonBit port, preserving byte/string-heavy behavior, replacing OCaml variants/records/exceptions/refs/arrays, mapping OCaml APIs to MoonBit packages, or building verification and test strategy for a migration. |
OCaml to MoonBit Migration
Port behavior, data invariants, and public contracts first. Translate syntax only after the source semantics are classified. The most common porting bug is silently coercing OCaml string (a byte sequence) into MoonBit String (UTF-16 text); classify every field by meaning before choosing a type.
When in doubt, probe with moon run -c '...'. Probes in this guide were verified on moon 0.1.20260512-class toolchains; rerun the relevant probe if the local toolchain is newer and the behavior is load-bearing.
Migration Workflow
- Inventory the OCaml module boundary: public types, functions, exceptions, optional arguments, mutable state, lazy/deferred state, C/Unix/filesystem dependencies, and existing tests or golden fixtures.
- Classify every OCaml
string by meaning before choosing a MoonBit type. Do this field by field, even inside the same OCaml record.
- Choose MoonBit package boundaries and imports before coding. Add imports to
moon.pkg; MoonBit source files do not use OCaml-style open.
- Port one behavioral slice at a time with tests. Prefer a thin public API skeleton, then fill parser/serializer/algorithm internals behind it.
- Probe uncertain language or library behavior with
moon run -c and, when needed, a small OCaml toplevel probe. Keep probes minimal.
- Finish each slice with
moon check, moon test, moon info, and moon fmt. You can add more warnings moon check --warn-list +... to be more strict.
Type-Mapping Cheatsheet
| OCaml use | MoonBit default |
|---|
| binary payload, file contents, compressed/encrypted/checksummed data, parser input | Bytes or BytesView |
| human-readable text, diagnostics, labels that are truly Unicode | String |
| single byte with known 0..255 range | Byte |
| indexes, counts, small identifiers, deliberate signed 32-bit wrapping | Int |
| file offsets, serialized positions, large object numbers | Int64 or UInt64 |
OCaml array with fixed length | FixedArray[T] |
| mutable growable builder | Array[T] |
| read-only sequence parameter | ArrayView[T] or BytesView |
| compile-time lookup table (literal or comprehension) | ReadOnlyArray[T] |
| keyed lookup with deterministic iteration | Map[K, V] |
OCaml Buffer.t byte builder | @buffer.Buffer |
OCaml ref | Ref[T] |
| OCaml variant | enum, often priv enum for internal states |
| OCaml record | struct, with { ..old, field: value } for immutable update |
| OCaml exception flow | suberror plus checked raise |
| lazy/deferred state | Lazy[T] |
OCaml int32 needing wrapping | Int |
OCaml int32 needing wide arithmetic | Int64 or UInt64 |
OCaml float | Double |
There is no Int32 type in the current toolchain.
Bytes vs String
OCaml String.length counts bytes; MoonBit String::length() counts UTF-16 code units. The same source character takes different positions in the two languages, and that difference is silent.
ocaml -noprompt -noinit <<'EOF'
let s = "𝄞";;
Printf.printf "%d\n" (String.length s);;
Printf.printf "%d\n" (Char.code s.[0]);;
EOF
moon run -c 'fn main { let s = "𝄞"; println(s.length()); println(s.char_length()) }'
String::length() is UTF-16 code units, String::char_length() is Unicode scalars, and neither is bytes. For byte-oriented formats use Bytes/BytesView. Convert between Bytes and String only through a named encoding helper (@ascii.encode, @utf8.encode, etc.) that documents the encoding assumption.
moon run -c 'fn main { let raw : Array[Byte] = [65, 0, 255]; let b = Bytes::from_array(raw); println(b.length()); println(b[2].to_int()) }'
moon run -c 'fn main { let source : Array[Byte] = [1, 2]; let bytes = Bytes::from_array(source); source[0] = 9; println(bytes[0].to_int()); println(source[0].to_int()) }'
Bytes::from_array produces an immutable owned Bytes. Mutating the source array afterwards does not affect the frozen value. Port OCaml Bytes mutators either as BytesView -> Bytes transforms, or keep state in Array[Byte]/FixedArray[Byte] until the final freeze.
moon run -c 'fn main { let empty = Bytes::new(0); let zeros = Bytes::new(2); println(empty.length()); println(zeros.length()); println(zeros[0].to_int()) }'
Bytes::new(length) always zero-fills and requires an explicit length. Use it where OCaml Bytes.empty or Bytes.make len '\000' would have appeared.
Bytes::copy is deprecated because Bytes is immutable. When a port must materialize a fresh physical copy (e.g. to preserve an OCaml promise that no buffer is shared), use Bytes::makei(len, i => src[i]).
moon run -c $'let cached_filter = b"/Filter"\nfn main { let b = b"PDF"; println(b.length()); println(b[0].to_int()); println(cached_filter.length()); println(cached_filter[0].to_int()) }'
b"..." is a compile-time byte-string literal of type Bytes. Use it for ASCII format syntax (b"/Filter", b"PDF", etc.) — no @ascii import, no runtime call, no type annotation needed at top level. Reserve @ascii.encode(text) for String → Bytes conversion when the source is a dynamic String. Do not build binary file formats by String concatenation.
moon run -c $'fn main { let buf = @buffer.new(); buf.write_bytes(b"PDF"); buf.write_byte(10); let bytes = buf.contents(); println(bytes.length()); println(bytes[0].to_int()); println(bytes[3].to_int()) }'
The canonical MoonBit byte-builder is @buffer.Buffer (from moonbitlang/core/buffer). Construct with @buffer.new() (optionally size_hint=N), append static ASCII via buf.write_bytes(b"..."), single bytes via buf.write_byte(n), dynamic text via buf.write_bytes(@ascii.encode(text)), binary payloads via buf.write_bytes(view), then freeze with buf.contents(). Reserve Array[Byte] plus Bytes::from_array for cases that need random-access mutation of in-flight bytes; Buffer is the OCaml Buffer.t analogue.
moon run -c 'fn main { let s = "/UniJIS-UCS2-H"; println(s.has_prefix("/Uni")); println(s.contains("-UCS2-")); println(s.has_suffix("-H")); println(s[1:]); println(s[1:].to_owned()) }'
String::has_prefix, has_suffix, and contains cover predicate work. Slice with s[start:end] — like BytesView, this returns a borrowed StringView, cheap and good for inspection/pattern matching. When the callee needs an owned String (e.g. for storage or a String parameter), use s[start:end].to_owned(). Offsets are UTF-16 code-unit offsets, not bytes; use these only for ASCII-validated tokens or genuinely textual parsing.
Bytes Views
moon run -c 'fn has_ab(view : BytesView) -> Bool { match view { [65, 66, ..] => true; _ => false } }
fn main { let bytes : Bytes = [65, 66, 67]; let view = bytes[:2]; println(view.length()); println(view[0].to_int()); println(has_ab(bytes[:])); println(has_ab(view)) }'
BytesView is the byte-sequence counterpart to ArrayView. Views are cheap slices, expose read-only byte operations, and support pattern matching with rest patterns. Prefer BytesView for read-only byte APIs; call .to_owned() only at explicit ownership boundaries. BytesView::to_owned() returns the original bytes for a whole view but allocates and copies for a partial slice.
Bytes is assignable to a BytesView parameter, so a single API can accept owned or borrowed input. A returned BytesView keeps its backing Bytes alive across function boundaries — useful for exposing decode/decrypt results without forcing a copy.
The reverse direction is not automatic: a BytesView does not type-check where owned Bytes is required, including in native extern declarations. Treat that as the ownership boundary and call .to_owned() deliberately. Equality works when the view is the left operand; if Bytes is on the left, slice it (bytes[:]) before comparing.
moon run -c 'fn main { let data : Bytes = [60, 65, 62, 0, 12, 60, 66, 62]; println(data.length()); println(data[3].to_int()); println(data[4].to_int()); let view = data[5:]; match view { [60, 66, 62] => println("match"); _ => println("miss") } }'
NUL (0) and form-feed (12) are ordinary bytes in Bytes/BytesView. Port OCaml byte predicates literally; do not narrow a format whitespace predicate to a string-oriented space/tab check.
Integers
moon run -c 'fn main { let (b, u, u16, i64, u64) : (Byte, UInt, UInt16, Int64, UInt64) = (255, 7, 65535, 42, 42); println(b.to_int()); println(u.to_string()); println(u16.to_int()); println(i64.to_string()); println(u64.to_string()) }'
Scalar types: Byte, Int16, UInt16, Int, UInt, Int64, UInt64, Float, Double. There is no Int32; use Int for deliberate signed 32-bit wrapping, or Int64/UInt64 when an OCaml int32 value must be represented without truncation. When calling a method on an integer literal, parenthesize: (65).to_byte(), not 65.to_byte() — 65. parses as the start of a float.
moon run -c 'fn main { let max = 2147483647; println(max + 1); println(1 << 31); println(0xF0 & 0x0F); println(0x80 >> 7); println(0xAA ^ 0xFF) }'
Int arithmetic wraps modulo 2^32 with signed interpretation. Symbolic operators map directly from OCaml: & (land), | (lor), ^ (lxor), << (lsl), >> (right shift, arithmetic for signed).
moon run -c 'fn main { println((-8) >> 1); println(1 << 32); println(1 >> 32); let logical = ((-8).reinterpret_as_uint() >> 1).reinterpret_as_int(); println(logical) }'
Int right shift is arithmetic, and shift counts are masked to 5 bits, so 1 << 32 == 1. For OCaml Int32.shift_right_logical, reinterpret signed→unsigned, shift, reinterpret back. Numeric conversion (.to_uint()) is not the same as bit reinterpretation (.reinterpret_as_uint()).
moon run -c $'fn main { try @string.parse_int("2147483648") catch { _ => println(true) } noraise { _ => println(false) }; let mut value = 0; for digit in [50,49,52,55,52,56,51,54,52,56] { value = value * 10 + digit - 48 }; println(value); let mut wide = 0L; for digit in [50,49,52,55,52,56,51,54,52,56] { wide = wide * 10L + (digit - 48).to_int64() }; println(wide.to_string()) }'
@string.parse_int rejects out-of-range decimals, but handwritten digit accumulation in Int silently wraps. Accumulate offsets, lengths, object numbers, or serialized counters in Int64/UInt64; bounds-check before narrowing to Int.
moon run -c 'fn main { println(0x8EA2A1A1 < 0); println(0x8EA2A1A1); println(0x7FFFFFFF < 0x8EA2A1A1); println((-1) % 256) }'
Int literals above 0x7FFFFFFF are negative. A table sorted by unsigned byte order is not sorted for signed Int comparison — compare through UInt (reinterpret_as_uint) or store as Int64/UInt64. % preserves the sign of the left operand; normalize (a - b) mod 256 style expressions into 0..255 before converting to Byte. For byte-codec arithmetic that multiplies by large radices, promote Byte to UInt64; UInt64::to_int() truncates to signed 32 bits for large values.
moon run -c 'fn rotr64(value : UInt64, bits : Int) -> UInt64 { (value >> bits) | (value << (64 - bits)) }