PONYλM2Modula-2

OCaml.CodeCompared.To/ReScript

An interactive executable cheatsheet comparing OCaml and ReScript

OCaml 5.2 ReScript 12.3
Hello World & the Build
Hello, World
Top-level statements run in order, so there is no unit-pattern binding to write.
let () = print_endline "Hello, World!"
Console.log("Hello, World!")
OCaml's let () = … both runs the expression and asserts it has type unit. A ReScript module's top-level expressions execute when the module loads, which is JavaScript's model rather than OCaml's. Console.log comes from the standard library; Js.log is the older spelling and both work in 12.3. Note there is no semicolon and no in — the layout rules are closer to JavaScript than to either.
dune and opam vs rescript.json and npm
Configuration rather than code, so neither column runs. The build is npm's, and one line of it decides what the output looks like.
(* dune-project *) (lang dune 3.16) (* bin/dune *) (executable (name main) (libraries str)) (* Build and run: dune build dune exec bin/main.exe Dependencies come from opam, into a switch. *)
// rescript.json // { // "name": "demo", // "sources": [{ "dir": "src", "subdirs": true }], // "package-specs": [{ "module": "esmodule", "in-source": true }], // "suffix": ".res.js" // } // // Build and run: // npm install // npx rescript build // node src/Main.res.js // // Dependencies come from npm, into ./node_modules.
ReScript compiles each .res file to one JavaScript file, and "in-source": true puts that output next to the source, so Main.res becomes Main.res.js in the same directory. That is deliberate: the generated JavaScript is meant to be read, checked into git if you like, and imported by ordinary JavaScript with no bundler special-casing. There is no module graph for the build system to work out the way dune does, because every file is compiled independently and the imports are ordinary JavaScript imports.
What the Compiler Emits
The output is the selling point, and it is worth knowing how little of a runtime comes with it.
(* ocamlopt emits native code linked against the OCaml runtime — a garbage collector, an exception mechanism and the standard library, all of it C. *) let double_all numbers = List.map (fun number -> number * 2) numbers let () = List.iter (Printf.printf "%d ") (double_all [ 1; 2; 3 ]); print_newline ()
// bsc emits JavaScript you can read. This function becomes // an ordinary JS function over an ordinary JS array, with // no runtime library involved at all. let doubleAll = numbers => Js.Array2.map(numbers, number => number * 2) Console.log(Js.Array2.joinWith(Js.Array2.map(doubleAll([1, 2, 3]), Js.Int.toString), " "))
js_of_ocaml compiles OCaml bytecode, so its output carries a translated runtime and is not meant to be read. ReScript compiles source to per-module JavaScript with no runtime for the core language — a variant becomes a plain object or a number, a record becomes a plain object, and a function becomes a function. That is what makes the output importable from JavaScript without a shim, and it is also why the type system has to be as strict as it is: there is no runtime representation to check anything against later.
The Syntax Changed, Not the Language
Functions Use Arrows and Parentheses
The same definitions, spelled the way JavaScript spells them — and this is the change that makes ReScript look foreign at first glance.
let add first second = first + second let describe items = List.length items let () = Printf.printf "%d\n" (add 3 4); Printf.printf "%d\n" (describe [ "a"; "b"; "c" ])
let add = (first, second) => first + second let rec describe = items => switch items { | list{} => 0 | list{_, ...rest} => 1 + describe(rest) } Console.log(add(3, 4)) Console.log(describe(list{"a", "b", "c"}))
OCaml writes a function by juxtaposing its parameters after the name and applies it by juxtaposition too. ReScript uses an arrow and parenthesized, comma-separated parameters, and application uses parentheses. Nothing about the language changed here — type inference works identically and add still has the type you would expect — but the visual difference is large enough that OCaml code does not read as ReScript at a glance. That was the point of the fork: it was meant to look approachable to JavaScript programmers.
match Becomes switch
Sum types and exhaustive matching survive completely. Three things change spelling and nothing changes meaning.
type shape = Circle of float | Rectangle of float * float | Point let area = function | Circle radius -> 3.14159 *. radius *. radius | Rectangle (width, height) -> width *. height | Point -> 0.0 let () = List.iter (fun shape -> Printf.printf "%.2f\n" (area shape)) [ Circle 1.0; Rectangle (2.0, 3.0); Point ]
type shape = Circle(float) | Rectangle(float, float) | Point let area = shape => switch shape { | Circle(radius) => 3.14159 *. radius *. radius | Rectangle(width, height) => width *. height | Point => 0.0 } Js.Array2.forEach([Circle(1.0), Rectangle(2.0, 3.0), Point], shape => Console.log(Js.Float.toFixedWithPrecision(area(shape), ~digits=2)) )
The keyword is switch … {} rather than match … with, the arrow is => rather than ->, and a constructor's payload is parenthesized (Circle(radius)) rather than juxtaposed. Exhaustiveness is checked exactly as in OCaml — delete the Point branch and the compiler names it. Note also that ReScript kept OCaml's separate float operators: *. is still float multiplication, because ReScript has no overloading either.
Modules Use Braces
Modules are the same construct with struct … end replaced by braces.
module Geometry = struct let pi = 3.14159 let circle_area radius = pi *. radius *. radius end let () = Printf.printf "%.4f\n" Geometry.pi; Printf.printf "%.2f\n" (Geometry.circle_area 2.0)
module Geometry = { let pi = 3.14159 let circleArea = radius => pi *. radius *. radius } Console.log(Js.Float.toFixedWithPrecision(Geometry.pi, ~digits=4)) Console.log(Js.Float.toFixedWithPrecision(Geometry.circleArea(2.0), ~digits=2))
This is a pure syntax change: ReScript modules nest, take signatures, and can be passed to functors exactly as OCaml's do. The naming convention shifts to camelCase for values, following JavaScript, while modules and constructors stay capitalized as in OCaml. A .res file is itself a module named after the file, and .resi is the interface file — the same arrangement as .ml and .mli.
The Literal Traps
Square Brackets Are an ARRAY
🚨 The single most disorienting difference on this page, because both spellings are valid in both languages and mean opposite things.
(* [ ... ] is a LIST. [| ... |] is an array. *) let () = let numbers = [ 1; 2; 3 ] in let scores = [| 10; 20; 30 |] in Printf.printf "list %d array %d\n" (List.length numbers) (Array.length scores)
// [ ... ] is an ARRAY — a real JavaScript array. // A list needs list{ ... }. let scores = [10, 20, 30] let numbers = list{1, 2, 3} let rec lengthOf = items => switch items { | list{} => 0 | list{_, ...rest} => 1 + lengthOf(rest) } Console.log2( "list " ++ Js.Int.toString(lengthOf(numbers)), "array " ++ Js.Int.toString(Js.Array2.length(scores)), )
In OCaml, [1; 2; 3] is a linked list and [|1; 2; 3|] is an array. In ReScript, [1, 2, 3] is an array — because it compiles to a JavaScript array, which is what JavaScript code will hand you — and a list requires the explicit list{1, 2, 3}. The consequence is that Array is the default collection in ReScript where List is the default in OCaml, which reverses which operations are cheap. Elements are also separated by commas rather than semicolons. Expect to be caught by this more than once.
Polymorphic Variants Use #
The feature is intact; only the sigil moved, and it moved for a concrete reason.
(* A polymorphic variant tag is written with a backtick. *) let describe value = match value with | `Circle radius -> radius * 2 | `Square side -> side * 4 let () = Printf.printf "%d\n" (describe (`Circle 3)); Printf.printf "%d\n" (describe (`Square 4))
// Same feature, and the backtick is now a STRING // delimiter — so the tag sigil moved to #. let describe = value => switch value { | #Circle(radius) => radius * 2 | #Square(side) => side * 4 } Console.log(describe(#Circle(3))) Console.log(describe(#Square(4)))
ReScript took JavaScript's template-literal syntax, so a backtick now opens an interpolated string — which meant the polymorphic-variant sigil had to become something else, and # was chosen. Everything else about them is the same: no declaration needed, a value can belong to several types, and the inferred type records which tags may appear. Note the other consequence for anyone writing about ReScript: backticks in ReScript source are string delimiters, so a code sample stored in a JavaScript template literal has to escape every one of them.
Backticks Interpolate
ReScript has interpolated strings, and unlike JavaScript's they will not silently stringify whatever you give them.
(* No interpolation syntax; sprintf is the tool, and it is type-checked. *) let () = let name = "OCaml" in let year = 1996 in print_endline (Printf.sprintf "%s appeared in %d" name year)
let name = "ReScript" let year = 2016 // A backtick string interpolates, and ${} accepts only // a string — so the int must be converted explicitly. Console.log(`${name} appeared in ${Js.Int.toString(year)}`)
A JavaScript template literal calls String() on any expression, so a mistake produces odd text rather than an error. ReScript's ${} accepts only a string, which is why Js.Int.toString appears above — the conversion is forced and visible. That is a smaller guarantee than OCaml's typed format strings, which check the whole directive list, but it is a much larger one than JavaScript offers. ReScript also has Printf-style formatting available, and it is rarely used.
Variables & Types
Type Inference
This is the part that survives completely, and it is the main reason to prefer ReScript to TypeScript.
(* Whole-program Hindley-Milner. Nothing is annotated and the types are still fully known. *) let first_or items fallback = match items with | [] -> fallback | head :: _ -> head let () = Printf.printf "%d\n" (first_or [ 1; 2 ] 0); print_endline (first_or [] "empty")
// The same inference, including the generalization — // firstOr works for any element type with no annotation. let firstOr = (items, fallback) => switch items { | list{} => fallback | list{head, ..._} => head } Console.log(firstOr(list{1, 2}, 0)) Console.log(firstOr(list{}, "empty"))
ReScript kept OCaml's inference engine, so parameters need no annotations, polymorphism is inferred and generalized automatically, and there is no any — every expression has a known type or the program does not compile. TypeScript, by contrast, infers only inside a function and treats an unannotated parameter as any. Note the list pattern syntax: list{} for the empty list and list{head, ..._} where OCaml writes head :: _.
There Is No null
option arrives unchanged, and — unlike every other JavaScript-targeting language — null is not a member of any type.
let find_even numbers = List.find_opt (fun number -> number mod 2 = 0) numbers let () = (match find_even [ 1; 3; 4 ] with | Some number -> Printf.printf "found %d\n" number | None -> print_endline "none found"); (match find_even [ 1; 3; 5 ] with | Some number -> Printf.printf "found %d\n" number | None -> print_endline "none found")
let findEven = numbers => Js.Array2.find(numbers, number => mod(number, 2) == 0) let describe = numbers => switch findEven(numbers) { | Some(number) => Console.log("found " ++ Js.Int.toString(number)) | None => Console.log("none found") } describe([1, 3, 4]) describe([1, 3, 5])
ReScript's option is OCaml's, with the same Some and None. What matters is what is absent: a ReScript type never includes null or undefined, so the check cannot be skipped. That is the sharpest contrast with TypeScript, where strictNullChecks is a flag you can turn off and where any value crossing an untyped boundary may be null regardless. At the JavaScript boundary ReScript has Js.Nullable for values that genuinely might be, and converting is explicit.
One Number Type, Almost
The type system keeps int and float apart even though the machine underneath has only one number.
(* int is 63 bits and float is a double. They are different types with different operators. *) let () = Printf.printf "%d\n" (7 / 2); Printf.printf "%.1f\n" (7.0 /. 2.0)
// int and float are still separate TYPES with separate // operators — but both compile to a JavaScript number, // so int is 32-bit and truncating division is emitted. Console.log(7 / 2) Console.log(Js.Float.toFixedWithPrecision(7.0 /. 2.0, ~digits=1))
This is a nice piece of engineering worth understanding. JavaScript has a single numeric type — a double — so ReScript's int is that double constrained to 32-bit integer behavior, with the compiler emitting the truncation and bitwise operations needed to keep it honest. The types stay separate, so 7 / 2 is integer division giving 3 and 7.0 /. 2.0 is 3.5, and mixing them is a compile error just as in OCaml. The practical difference from OCaml is the width: 32 bits rather than 63, so large integers need float or a bigint binding.
Shadowing
Rebinding a name to a value of a different type works at every level, including the top level of a module.
let () = let value = 5 in let value = value * 2 in let value = string_of_int value ^ " points" in print_endline value
let value = 5 let value = value * 2 let value = Js.Int.toString(value) ++ " points" Console.log(value)
ReScript kept OCaml's behaviour exactly: each let creates a fresh binding that hides the previous one, nothing is mutated, and the type may change on the way. That is worth noting because it is the one place F# diverges — a module-level let there compiles to a static class member and redeclaring the name is error FS0037. ReScript compiles a module-level binding to a JavaScript let with a generated suffix, so shadowing costs nothing and reads exactly as it does in OCaml.
Strings
Strings Are JavaScript Strings
The same word gives two different lengths, and only one column's answer is Unicode-aware at all.
let () = let text = "caffè" in Printf.printf "length = %d\n" (String.length text); print_endline (String.uppercase_ascii text)
let text = "caffè" // A ReScript string IS a JavaScript string: UTF-16 code // units, and every JS string method is available. Console.log("length = " ++ Js.Int.toString(Js.String2.length(text))) Console.log(Js.String2.toUpperCase(text))
An OCaml string is a sequence of bytes with no declared encoding, so this word occupies six of them and uppercase_ascii — which says in its own name that it only handles ASCII — leaves the accented letter alone. A ReScript string is a JavaScript string: UTF-16 code units, so the length is five, and toUpperCase is fully Unicode-aware. This is a straightforward improvement, and it comes free from the target platform rather than from any design decision.
Splitting and Joining
The operations correspond; note that splitting gives an array here, not a list.
let () = let line = "alpha,beta,gamma" in let parts = String.split_on_char ',' line in List.iter (fun part -> Printf.printf "<%s>" part) parts; print_newline (); print_endline (String.concat " | " parts)
let line = "alpha,beta,gamma" let parts = Js.String2.split(line, ",") Console.log(Js.Array2.joinWith(Js.Array2.map(parts, part => "<" ++ part ++ ">"), "")) Console.log(Js.Array2.joinWith(Js.Array2.map(parts, part => part), " | "))
This is the array-by-default consequence in practice: Js.String2.split returns array<string> because that is what JavaScript's String.prototype.split returns, so downstream code uses Belt.Array rather than Belt.List. OCaml's split_on_char gives a list, and String.concat takes one. The Js.String2 module is a thin, type-safe binding over JavaScript's own string methods — which is a good illustration of how much of ReScript's standard library is bindings rather than implementations.
Collections
The Array Is the Default
The reversal of which collection is idiomatic is the most practical consequence of targeting JavaScript.
(* The list is the default: immutable, linked, cheap to cons onto the front. *) let () = let numbers = [ 1; 2; 3 ] in let extended = 0 :: numbers in Printf.printf "extended %d, original %d\n" (List.length extended) (List.length numbers)
// The array is the default: a real JavaScript array, // mutable, cheap to push onto the back. let numbers = [1, 2, 3] let extended = Js.Array2.concat([0], numbers) Console.log( "extended " ++ Js.Int.toString(Js.Array2.length(extended)) ++ ", original " ++ Js.Int.toString(Js.Array2.length(numbers)), )
ReScript still has OCaml's immutable linked list under list{} and Belt.List, and it is perfectly usable. But every JavaScript value that arrives from outside is an array, every binding returns arrays, and arrays are what JavaScript code expects back — so idiomatic ReScript uses array and Belt.Array throughout. The habit to change is the OCaml one of building a result by consing and reversing at the end; here you build an array. Note that ReScript arrays are mutable, so Js.Array2.concat above is what keeps the original intact.
Belt: A Standard Library That Cannot Raise
Belt inverts OCaml's naming convention, and the inversion is a real safety improvement.
(* The standard library raises: List.hd on an empty list raises Failure, and nth raises Invalid_argument. The _opt variants are the safe ones. *) let () = (match List.nth_opt [ 1; 2; 3 ] 10 with | Some value -> Printf.printf "%d\n" value | None -> print_endline "out of range"); (try Printf.printf "%d\n" (List.hd []) with | Failure message -> Printf.printf "raised: %s\n" message)
// Belt returns option by DEFAULT. The raising versions // carry an Exn suffix, so the dangerous one is the one // you have to type out. switch [1, 2, 3][10] { | Some(value) => Console.log(value) | None => Console.log("out of range") } let empty: array<int> = [] switch empty[0] { | Some(value) => Console.log(value) | None => Console.log("raised: nothing — the index access returned None") }
OCaml names the raising function plainly (List.hd, List.nth) and suffixes the safe one with _opt, so the dangerous version is the shorter one and the one you reach for by reflex. ReScript inverts that at the language level: indexing an array returns an option, so the safe form is the one you get for free and the raising variant has to be spelled out as Js.Array2.unsafe_get. The same convention runs through its libraries — Belt.Array.get returns an option and Belt.Array.getExn is the one that can crash. ReScript ships three standard libraries in 12.3: Js (thin bindings over JavaScript's own), Belt (data structures, option-returning) and the newer Core.
map, filter and reduce
The pipeline survives, with a different operator and one renamed function.
let () = [ 1; 2; 3; 4; 5; 6 ] |> List.filter (fun number -> number mod 2 = 0) |> List.map (fun number -> number * 2) |> List.fold_left ( + ) 0 |> Printf.printf "total = %d\n"
let total = [1, 2, 3, 4, 5, 6] ->Js.Array2.filter(number => mod(number, 2) == 0) ->Js.Array2.map(number => number * 2) ->Js.Array2.reduce((sum, number) => sum + number, 0) Console.log("total = " ++ Js.Int.toString(total))
ReScript's -> is the pipe-first operator: it inserts the left-hand value as the first argument of the call on the right. OCaml's |> is pipe-last, inserting it as the final argument. That difference exists because Belt takes its data first (so it reads well with -> and works with uncurried application), while OCaml's modules take data last (so it reads well with |> and partial application). ReScript also has |> for compatibility, and mixing the two is a common source of confusion. filter is spelled keep in Belt.
Maps and Dictionaries
ReScript has both a persistent map and a plain JavaScript object, and which one to reach for depends on where the data came from.
module StringMap = Map.Make (String) let () = let ages = StringMap.add "alan" 41 (StringMap.add "ada" 36 StringMap.empty) in Printf.printf "entries = %d\n" (StringMap.cardinal ages); match StringMap.find_opt "ada" ages with | Some age -> Printf.printf "ada is %d\n" age | None -> print_endline "unknown"
// Js.Dict is a plain JavaScript object with string keys — // the thing JSON gives you, and what bindings return. let ages = Js.Dict.fromArray([("ada", 36), ("alan", 41)]) Console.log("entries = " ++ Js.Int.toString(Js.Array2.length(Js.Dict.keys(ages)))) switch Js.Dict.get(ages, "ada") { | Some(age) => Console.log("ada is " ++ Js.Int.toString(age)) | None => Console.log("unknown") }
OCaml's Map.Make (String) builds a functor-instantiated module with a persistent balanced tree. ReScript has that too — Belt.Map.String — and it also has Js.Dict, which is a plain JavaScript object with string keys. The second is what a JSON parse produces and what most bindings return, so it appears constantly at the boundary, while Belt.Map is what you use once the data is inside your own code. Note that Js.Dict.get returns an option, so a missing key is handled rather than yielding undefined.
Control Flow
if Is an Expression
Both make if an expression that produces a value — which JavaScript does not, so this is inherited from OCaml rather than from the target.
let () = let temperature = 31 in let advice = if temperature > 30 then "stay inside" else if temperature > 20 then "pleasant" else "bring a coat" in print_endline advice
let temperature = 31 let advice = if temperature > 30 { "stay inside" } else if temperature > 20 { "pleasant" } else { "bring a coat" } Console.log(advice)
The braces are mandatory and the then keyword is gone, but the semantics are OCaml's: every branch must agree on a type, and an if with no else must have type unit. That is a real difference from the JavaScript being emitted, where if is a statement — the compiler turns the ReScript expression into a JavaScript conditional expression or a temporary, whichever fits. ReScript also has the ternary condition ? a : b, which is the same thing more compactly.
Loops
The counting for is OCaml's, and so is the ref cell the while counter needs.
let () = for index = 1 to 3 do Printf.printf "for %d\n" index done; let countdown = ref 3 in while !countdown > 0 do Printf.printf "while %d\n" !countdown; countdown := !countdown - 1 done
for index in 1 to 3 { Console.log("for " ++ Js.Int.toString(index)) } let countdown = ref(3) while countdown.contents > 0 { Console.log("while " ++ Js.Int.toString(countdown.contents)) countdown := countdown.contents - 1 }
ReScript kept OCaml's for … to … and its downward downto, and dropped the done keyword in favour of braces. It also kept the constraint that made the ref necessary: there are no mutable local bindings, so a counter lives in a cell read through .contents. That is a slightly odd inheritance given the target — JavaScript has let — and it is one of the places ReScript chose OCaml's semantics over the platform's. Iterating a collection uses Js.Array2.forEach rather than a loop.
Tail Calls Are Not Eliminated
A guarantee OCaml makes and ReScript cannot, because the guarantee belongs to the machine underneath.
(* OCaml guarantees tail-call elimination, so this runs in constant stack space at any depth. *) let rec count_down counted current = if current = 0 then counted else count_down (counted + 1) (current - 1) let () = Printf.printf "%d\n" (count_down 0 100000)
// JavaScript engines do not implement tail calls, so the // same recursion would overflow the stack. Write a loop. let countDown = limit => { let counted = ref(0) for _ in 1 to limit { counted := counted.contents + 1 } counted.contents } Console.log(countDown(100000))
OCaml eliminates tail calls as a language property, so accumulator-passing recursion is safe at any depth and is the idiomatic way to write a loop. ReScript compiles to JavaScript, and although tail calls are in the ECMAScript specification no major engine implements them — so a hundred-thousand-deep recursion raises RangeError: Maximum call stack size exceeded. The compiler does turn a directly self-recursive tail call into a loop in many cases, but it is an optimization rather than a promise. Where you would write a tail-recursive helper in OCaml, write a loop here.
Guards in a switch
Guards, or-patterns and the wildcard all survive; only the guard keyword changes.
let classify number = match number with | 0 -> "zero" | n when n < 0 -> "negative" | 1 | 2 | 3 -> "small" | _ -> "ordinary" let () = List.iter (fun number -> Printf.printf "%d is %s\n" number (classify number)) [ 0; -5; 2; 42 ]
let classify = number => switch number { | 0 => "zero" | n if n < 0 => "negative" | 1 | 2 | 3 => "small" | _ => "ordinary" } Js.Array2.forEach([0, -5, 2, 42], number => Console.log(Js.Int.toString(number) ++ " is " ++ classify(number)) )
ReScript writes if where OCaml writes when, matching Rust's choice. Everything else is identical, including the rule that a guard does not count toward exhaustiveness — so the wildcard is required in both columns. This is worth contrasting with the TypeScript page: TypeScript's switch compares values with === and supports none of this, so a discriminated union plus a manual never check is the closest it gets.
Functions & Uncurrying
Application Is Uncurried
The largest semantic change from OCaml, and the one that most affects how existing OCaml code translates.
(* Every function of two arguments is a function returning a function, so this is ordinary application. *) let add first second = first + second let () = let add_ten = add 10 in Printf.printf "%d\n" (add_ten 5); Printf.printf "%d\n" (add_ten 32)
// Since v11, functions are UNCURRIED by default: add // takes two arguments at once. Partial application is // still available, and has to be asked for with ... let add = (first, second) => first + second let addTen = add(10, ...) Console.log(addTen(5)) Console.log(addTen(32))
ReScript versions before 11 were curried like OCaml, and that produced JavaScript output full of arity-adapting helpers — a two-argument function called with one argument had to build a closure at runtime, which JavaScript engines could not optimize. Uncurrying by default makes add(3, 4) compile to a plain add(3, 4) call. Partial application did not disappear: add(10, ...) asks for it explicitly, and the compiler generates the closure only where you said so. The practical effect is that point-free OCaml pipelines need rewriting, and everything reads a little more explicitly.
Labeled and Optional Arguments, Kept
Kept in full, trailing unit and all — which is more than F# or Haskell can say.
let greet ?(greeting = "Hello") ~name () = Printf.sprintf "%s, %s!" greeting name let () = print_endline (greet ~name:"Ada" ()); print_endline (greet ~greeting:"Welcome" ~name:"Alan" ())
let greet = (~greeting="Hello", ~name, ()) => greeting ++ ", " ++ name ++ "!" Console.log(greet(~name="Ada", ())) Console.log(greet(~greeting="Welcome", ~name="Alan", ()))
The ~ for a label and the = for a default are OCaml's, and so is the trailing (), which exists for the same reason: a function with optional arguments cannot otherwise be known to be fully applied. F# has optional parameters only on class members, and Haskell has nothing at all, so this is a place where ReScript is the closest of the three to OCaml. Note that labels survive into the emitted JavaScript as positional arguments — they are a compile-time feature with no runtime cost.
Higher-Order Functions
Functions are first-class in both, with one function type and no taxonomy of closure kinds.
let apply_twice transform value = transform (transform value) let () = Printf.printf "%d\n" (apply_twice (fun number -> number * 3) 2); Printf.printf "%d\n" (apply_twice succ 10)
let applyTwice = (transform, value) => transform(transform(value)) Console.log(applyTwice(number => number * 3, 2)) Console.log(applyTwice(number => number + 1, 10))
This transfers with no adjustment beyond the syntax. Neither language needs the Fn/FnMut/FnOnce distinction Rust requires, because neither tracks ownership, and neither needs a type parameter with a bound the way Rust does. A ReScript closure compiles to a JavaScript closure, so it captures by reference in the JavaScript sense — which is invisible unless you are also mutating, and ReScript makes mutation explicit enough that it rarely surprises.
Records
Records
Records carry over with a comma, a colon, and JavaScript's spread standing in for with.
type point = { x : int; y : int } let () = let origin = { x = 0; y = 0 } in let shifted = { origin with x = 5 } in Printf.printf "%d %d\n" origin.x origin.y; Printf.printf "%d %d\n" shifted.x shifted.y
type point = {x: int, y: int} let origin = {x: 0, y: 0} let shifted = {...origin, x: 5} Console.log2(origin.x, origin.y) Console.log2(shifted.x, shifted.y)
The correspondence is close to exact. Field definitions use : instead of : — the same, in fact — separated by commas rather than semicolons, and construction uses : where OCaml uses =. Functional update is JavaScript's spread syntax, {...origin, x: 5}, which does what { origin with x = 5 } does. Underneath, a ReScript record compiles to a plain JavaScript object with the same field names, which is what makes it directly usable from JavaScript and directly serializable to JSON.
Mutable Fields, Kept
The mutable keyword is OCaml's; only the assignment operator changed.
type counter = { mutable total : int } let () = let counter = { total = 0 } in counter.total <- counter.total + 5; counter.total <- counter.total + 5; Printf.printf "%d\n" counter.total
type counter = {mutable total: int} let counter = {total: 0} counter.total = counter.total + 5 counter.total = counter.total + 5 Console.log(counter.total)
ReScript kept mutable record fields exactly as OCaml has them — declared on the field, applying to every holder of the record. The assignment is written = rather than <-, following JavaScript, which is a small trap in the other direction since = in OCaml is comparison. ReScript also kept ref cells, with ref(0), := and .contents — and like OCaml they are just a record with one mutable field rather than a language feature.
Destructuring and Punning
Destructuring in a parameter position and field punning both carry over, and JavaScript happens to spell punning the same way.
type person = { name : string; age : int } let describe { name; age } = Printf.sprintf "%s is %d" name age let () = print_endline (describe { name = "Ada"; age = 36 }); let name = "Alan" and age = 41 in print_endline (describe { name; age })
type person = {name: string, age: int} let describe = ({name, age}) => name ++ " is " ++ Js.Int.toString(age) let name = "Alan" let age = 41 Console.log(describe({name: "Ada", age: 36})) Console.log(describe({name, age}))
OCaml lets a record be destructured directly in a function parameter and lets { name; age } mean { name = name; age = age } when the variables already have those names. ReScript does both, and the punning syntax coincides with JavaScript's object shorthand — which means the emitted code is the obvious thing. This is one of the places where an OCaml habit, a ReScript feature and a JavaScript idiom all line up, which does not happen often.
Variants & Pattern Matching
option and result
Both types arrive unchanged, including the guard — spelled if here rather than when.
let parse_positive text = match int_of_string_opt text with | None -> Error (Printf.sprintf "%S is not a number" text) | Some number when number <= 0 -> Error "must be positive" | Some number -> Ok number let () = (match parse_positive "42" with | Ok number -> Printf.printf "%d\n" number | Error message -> print_endline message); (match parse_positive "oops" with | Ok number -> Printf.printf "%d\n" number | Error message -> print_endline message)
let parsePositive = text => switch Js.Float.fromString(text) { | parsed if Js.Float.isNaN(parsed) => Error("\"" ++ text ++ "\" is not a number") | parsed if parsed <= 0.0 => Error("must be positive") | parsed => Ok(Int.fromFloat(parsed)) } let describe = text => switch parsePositive(text) { | Ok(number) => Console.log(number) | Error(message) => Console.log(message) } describe("42") describe("oops")
option and result are OCaml's, with the same constructors. The only change visible above is the guard keyword: ReScript writes if where OCaml writes when, following the same choice Rust made. Neither language has an equivalent of Rust's ? operator, so chaining several fallible steps means Belt.Result.flatMap or nesting — the same position OCaml is in without a let* binding operator, which ReScript does not have.
Recursive Types
Recursive types need no boxing and no indirection — one small keyword is the only addition.
type tree = Leaf | Node of tree * int * tree let rec total tree = match tree with | Leaf -> 0 | Node (left, value, right) -> total left + value + total right let () = let sample = Node (Node (Leaf, 1, Leaf), 2, Node (Leaf, 3, Leaf)) in Printf.printf "%d\n" (total sample)
type rec tree = Leaf | Node(tree, int, tree) let rec total = tree => switch tree { | Leaf => 0 | Node(left, value, right) => total(left) + value + total(right) } let sample = Node(Node(Leaf, 1, Leaf), 2, Node(Leaf, 3, Leaf)) Console.log(total(sample))
ReScript requires type rec to declare a recursive type, where OCaml's type is recursive by default. That is a deliberate reversal, matching let rec, so recursion is always marked. Everything else is the same: no Box as Rust needs, because both languages heap-allocate variant payloads. Note that in the emitted JavaScript, Leaf becomes the number 0 and Node(…) becomes a small object — constant constructors compile to integers, which is exactly what OCaml does at runtime too.
Polymorphic Variants, Kept
They Really Are Still Here
Worth stating plainly because ReScript's reputation says otherwise: polymorphic variants were not dropped.
(* No declaration needed, and a value can belong to several types at once. *) let color_name value = match value with | `Red -> "red" | `Green -> "green" | `Blue -> "blue" let () = List.iter (fun color -> Printf.printf "%s " (color_name color)) [ `Red; `Green; `Blue ]; print_newline ()
let colorName = value => switch value { | #Red => "red" | #Green => "green" | #Blue => "blue" } Console.log(Js.Array2.joinWith(Js.Array2.map([#Red, #Green, #Blue], colorName), " "))
F# has no equivalent and Haskell has no equivalent, so this is a feature ReScript keeps that the two better-known ML descendants both abandoned. The semantics are OCaml's: no declaration, structural typing, and an inferred type listing exactly which tags may appear. The one thing to know is that a polymorphic variant compiles to a JavaScript string#Red becomes "Red" — which is why they are the idiomatic way to bind a JavaScript API that takes a fixed set of string values.
And They Are the Interop Tool
The reason polymorphic variants survived the fork is that they turned out to be exactly the right tool for the new target.
(* A polymorphic variant is a compile-time thing with a runtime representation OCaml chooses. There is no external system to line it up with. *) let alignment_to_string value = match value with | `Left -> "left" | `Center -> "center" | `Right -> "right" let () = print_endline (alignment_to_string `Center)
// #Center compiles to the string "center" — so a binding // to a JavaScript API that expects "left" | "center" | // "right" is exact, checked, and needs no conversion. type alignment = [#left | #center | #right] let describe = (value: alignment) => switch value { | #left => "left" | #center => "center" | #right => "right" } Console.log(describe(#center))
A JavaScript API that accepts one of a fixed set of strings — a CSS alignment, an HTTP method, a chart type — is extremely common, and TypeScript models it with a union of string literal types. ReScript models it with a polymorphic variant, which compiles to precisely those strings with no runtime conversion, and gets exhaustiveness checking on top. That is a case where OCaml's most exotic feature turned out to be the practical one, and it is a good argument for why the fork kept it.
Modules & Functors, Kept
Functors Really Are Still Here
The second thing ReScript is widely believed to have dropped, and it works — this example compiles and runs.
module type Comparable = sig type t val compare : t -> t -> int end module MakeLargest (Element : Comparable) = struct let largest items = Array.fold_left (fun best item -> if Element.compare item best > 0 then item else best) items.(0) items end module IntCompare = struct type t = int let compare = compare end module LargestInt = MakeLargest (IntCompare) let () = Printf.printf "%d\n" (LargestInt.largest [| 3; 9; 4 |])
module type Comparable = { type t let compare: (t, t) => int } module MakeLargest = (Element: Comparable) => { let largest = (items: array<Element.t>) => Js.Array2.reduce(items, (best, item) => Element.compare(item, best) > 0 ? item : best , Js.Array2.unsafe_get(items, 0)) } module IntCompare = { type t = int let compare = (first: int, second: int) => first < second ? -1 : first > second ? 1 : 0 } module LargestInt = MakeLargest(IntCompare) Console.log(LargestInt.largest([3, 9, 4]))
Module signatures, functors and functor application are all present, with sig … end becoming braces and the functor written as an arrow from a module to a module. F# has no functors at any level and Haskell has none either, so among the three descendants on this anchor ReScript is the only one that kept OCaml's module system essentially intact. The one thing to check before relying on it is tooling: functors are less used in the ReScript community than in OCaml's, so editor support and library conventions assume them less.
First-Class Modules
Packing a module into a value survives too, with unpack replacing OCaml's (val …).
module type Greeter = sig val greet : unit -> string end module English = struct let greet () = "Hello" end module French = struct let greet () = "Bonjour" end let () = let greeters : (module Greeter) list = [ (module English); (module French) ] in List.iter (fun greeter -> let module G = (val greeter : Greeter) in print_endline (G.greet ())) greeters
module type Greeter = {let greet: unit => string} module English = {let greet = () => "Hello"} module French = {let greet = () => "Bonjour"} let greeters: array<module(Greeter)> = [module(English), module(French)] Js.Array2.forEach(greeters, greeter => { module G = unpack(greeter) Console.log(G.greet()) })
The mechanism is the same: module(English) packs and unpack retrieves, giving runtime dispatch over modules that share a signature. This is the OCaml feature with no counterpart in most languages, and ReScript kept it. The syntax is arguably cleaner — unpack(greeter) reads better than (val greeter : Greeter), and the signature is inferred rather than repeated.
Interface Files
Abstract types behind a signature work identically, and the file-level version is .resi rather than .mli.
(* An .mli file lists what a module exports; anything absent is private. Here the same effect inline. *) module Counter : sig type t val create : unit -> t val bump : t -> t val value : t -> int end = struct type t = int let create () = 0 let bump counter = counter + 1 let value counter = counter end let () = Printf.printf "%d\n" (Counter.value (Counter.bump (Counter.create ())))
// The same, and in a real project this signature would // live in Counter.resi beside Counter.res. module Counter: { type t let create: unit => t let bump: t => t let value: t => int } = { type t = int let create = () => 0 let bump = counter => counter + 1 let value = counter => counter } Console.log(Counter.value(Counter.bump(Counter.create())))
The type t declared without a definition is opaque to callers exactly as in OCaml, so Counter.t cannot be treated as an int from outside. A real project puts this in Counter.resi, which plays the role of Counter.mli — same idea, same effect on dead-code elimination, and the same discipline of writing the interface first. TypeScript has no equivalent: its export list controls visibility but cannot hide a type's definition while exposing its name.
Error Handling
Exceptions, Kept
The exception declaration and raise are OCaml's; only with becomes catch.
exception Too_large of int let check value = if value > 100 then raise (Too_large value) else value let () = Printf.printf "%d\n" (check 50); (try Printf.printf "%d\n" (check 500) with | Too_large value -> Printf.printf "too large: %d\n" value)
exception TooLarge(int) let check = value => value > 100 ? raise(TooLarge(value)) : value Console.log(check(50)) try { Console.log(check(500)) } catch { | TooLarge(value) => Console.log("too large: " ++ Js.Int.toString(value)) }
ReScript exceptions are declared and matched exactly as OCaml's, with pattern matching in the handler and the payload bound. They compile to JavaScript exceptions, so they interoperate with throw from JavaScript — and that is the caveat: a JavaScript exception arriving from a binding is not one of your declared exceptions, so a handler needs a catch-all or Js.Exn to inspect it. The community leans on result more heavily than OCaml's does, largely because the JavaScript boundary makes exceptions less predictable.
JavaScript Interop
external Binds a JavaScript Function
The same keyword an OCaml programmer already knows, pointed at a different foreign language — and this one is far easier to get right.
(* external gives a foreign function an OCaml type the compiler trusts without checking. "%identity" is a COMPILER PRIMITIVE, so this one is the rare external that needs nothing linked; a real C binding needs a stub compiled and linked alongside. *) external identity : 'a -> 'a = "%identity" let () = Printf.printf "%d\n" (identity 42)
// The SAME keyword, binding a JavaScript function // instead. @val says it is a global; the type is a // claim the compiler trusts. @val external parseInt: (string, int) => int = "parseInt" Console.log(parseInt("42", 10))
Both languages use the same keyword to give a foreign function a type the compiler trusts without checking, and both print 42 here. The difference is what it costs to point that keyword at something real. The anchor column uses "%identity", a compiler primitive — the one kind of OCaml external that links to nothing; a genuine C binding needs a stub compiled and linked, with the value representation and CAMLparam root registration correct or the collector corrupts memory. ReScript's binding is one line with an attribute: no stub, no build step, and the worst failure is a type that does not match, which produces wrong behavior rather than a corrupted heap. Attributes such as @val, @module, @send and @get say where the thing lives.
Binding an npm Package
This is the practical reason a ReScript project moves faster than an OCaml one on the frontend.
(* This one is in OCaml's own standard library, so no binding is needed at all. Reaching a C library that is NOT would mean a stub file, a dune (foreign_stubs) block, and the library on the linker's path. *) let () = print_endline (Filename.basename "/usr/local/bin/ocaml")
// @module says which JavaScript module to import from. // The compiler emits the import; there is no stub. @module("node:path") external basename: string => string = "basename" Console.log(basename("/usr/local/bin/ocaml"))
Both columns print ocaml, and reached it differently: OCaml has Filename.basename in its own standard library, while ReScript imported Node's path module in one line. That is the comparison in miniature — where OCaml's answer is not already in the standard library, binding a C library means writing stub functions, declaring them in dune, and getting the header and the library onto the compiler's and linker's paths. Binding a JavaScript module is a type annotation and an attribute — the compiler emits an ordinary import and the bundler does the rest. There is a large repository of pre-written bindings (rescript-webapi and the like), and writing one for a small API is a few minutes' work. The trade is the same as any FFI: the type you write is unverified, so a wrong binding fails at runtime.
JSON at the Boundary
Because a record compiles to a plain JavaScript object, serialization is free — and deserialization is the part that still needs care.
(* OCaml's standard library has no JSON; yojson or a ppx deriver supplies it, with a checked conversion. *) type person = { name : string; age : int } let to_json person = Printf.sprintf {|{"name":"%s","age":%d}|} person.name person.age let () = print_endline (to_json { name = "Ada"; age = 36 })
type person = {name: string, age: int} // A record IS a JavaScript object, so serializing is the // platform's own function and needs no deriver. let person = {name: "Ada", age: 36} Console.log( switch Js.Json.stringifyAny(person) { | Some(text) => text | None => "null" }, )
The record's field names survive into the emitted object, so JSON.stringify produces exactly what you would expect and OCaml's ppx-deriver step is unnecessary. Parsing is the asymmetric half: Js.Json.parseExn gives you an untyped Js.Json.t, and turning that into a person requires a decoder that actually checks the shape — the same discipline TypeScript needs, and for the same reason. Asserting the type instead is possible and is the one place ReScript lets you be unsound.
Binding a Method
The attribute vocabulary is small, and @send is the one that makes a binding read like the method it is.
(* Both of these are plain standard-library functions, called the ordinary way. OCaml has no notion of a method, so there is nothing to bind. *) let () = print_endline (String.uppercase_ascii "ada"); print_endline (Printf.sprintf "%03d" 7)
// @send binds a METHOD: the first parameter becomes the // receiver, so it reads left to right with ->. @send external toUpperCase: string => string = "toUpperCase" @send external padStart: (string, int, string) => string = "padStart" Console.log("ada"->toUpperCase) Console.log("7"->padStart(3, "0"))
Both columns print ADA and 007. OCaml calls two ordinary functions, because it has no notion of a method at all. @send declares that the first parameter is the object the method is called on, so "ada"->toUpperCase emits "ada".toUpperCase(). That is why the pipe-first operator exists: with data first, a chain of method bindings reads in call order. The companions are @val for a global, @module for an import, @get and @set for properties, and @new for a constructor. Together they cover almost every JavaScript API shape, and none of them needs a stub file, a build step, or anything linked.
Async & Promises
async and await
Both print the same three lines. What differs is how far the change spreads through the rest of the program.
(* OCaml 5 effect handlers let an ORDINARY function suspend; nothing above it is marked and its type does not change. *) open Effect open Effect.Deep type _ Effect.t += Pause : unit Effect.t let task () = print_endline "before"; perform Pause; print_endline "after"; 42 let () = let result = match_with task () { retc = (fun value -> value) ; exnc = raise ; effc = (fun (type a) (performed : a Effect.t) -> match performed with | Pause -> Some (fun (continuation : (a, _) continuation) -> continue continuation ()) | _ -> None) } in Printf.printf "%d\n" result
// async/await maps onto JavaScript promises, and colors // every caller: task must be async, and so must anything // that awaits it. let task = async () => { Console.log("before") await Promise.resolve() Console.log("after") 42 } // Top-level await: the module itself waits, so nothing is // left running after the program is considered finished. Console.log(await task())
ReScript's async/await compiles to JavaScript promises, so it inherits JavaScript's function coloring: an awaiting function must be async, its return type becomes a promise, and every caller wanting the value must await and therefore be async too. OCaml 5's effect handlers avoid that entirely — task is an ordinary unit -> int that suspends anyway, because the handler decides what suspension means. Note the trailing ->ignore: ReScript warns about an unused promise, which is a small guard against the forgotten-await bug.
Several Things at Once
Both fan work out and gather it back. Only one of them uses more than one core.
(* Domains are OS threads with their own minor heap: real parallelism across cores. *) let () = let square value = Domain.spawn (fun () -> value * value) in let workers = List.map square [ 1; 2; 3; 4 ] in let results = List.map Domain.join workers in List.iter (Printf.printf "%d ") results; print_newline ()
// One thread, one event loop. Promise.all overlaps // WAITING; it never overlaps computation. let square = async value => value * value let results = await Promise.all(Js.Array2.map([1, 2, 3, 4], square)) Console.log(Js.Array2.joinWith(Js.Array2.map(results, Js.Int.toString), " "))
OCaml 5's Domain is an operating-system thread with its own minor heap, so several genuinely run at once. JavaScript is single-threaded: Promise.all starts several asynchronous operations and waits for all of them, which overlaps waiting on the network or a timer but never overlaps computation — and a CPU-bound loop blocks the whole event loop. Real parallelism means Web Workers or Node's worker_threads, which communicate by message passing and share no memory. That is a genuine capability an OCaml program has and a ReScript one does not.
What You Actually Give Up
There Is No Native Target
The largest thing given up, and it is a decision about scope rather than a missing feature.
(* ocamlopt produces a native binary. The same source also compiles to bytecode, to JavaScript via js_of_ocaml or Melange, and to WebAssembly. *) let rec fibonacci number = if number < 2 then number else fibonacci (number - 1) + fibonacci (number - 2) let () = Printf.printf "%d\n" (fibonacci 20)
// ReScript emits JavaScript, and only JavaScript. There // is no native backend and no plan for one. let rec fibonacci = number => number < 2 ? number : fibonacci(number - 1) + fibonacci(number - 2) Console.log(fibonacci(20))
OCaml compiles to native code, to bytecode, to JavaScript through two separate projects, and to WebAssembly — so one codebase can serve a command-line tool, a server and a browser. ReScript targets JavaScript and nothing else, deliberately, and its whole design follows from that: uncurried application for clean output, arrays as the default collection, records as plain objects. If the program needs to run outside a JavaScript host, ReScript is the wrong tool and Melange is the interesting middle — it compiles real OCaml to JavaScript with the same npm interop, so the same source can also target native.
A Smaller Standard Library and Ecosystem
Worth being honest about, because the count of ReScript packages and the count of usable libraries are very different numbers.
(* opam carries several thousand packages, including heavy ones: Eio, Lwt, Core, Dream, Irmin. The standard library itself is small but the ecosystem is broad. *) let () = let numbers = [ 4; 8; 15; 16 ] in Printf.printf "%d\n" (List.fold_left ( + ) 0 numbers)
// The ReScript-specific package set is small. What is // large is npm — reachable through bindings, which is a // different kind of access. let numbers = [4, 8, 15, 16] Console.log(Js.Array2.reduce(numbers, (sum, number) => sum + number, 0))
The set of libraries written in ReScript is modest. The set reachable from it is all of npm, through bindings you either find or write — and writing one for a small API is genuinely quick, as the interop section showed. Which of those two numbers matters depends on the work: a React frontend is well served, because the React bindings are first-class and maintained by the core team, while something needing a specific typed library will more often mean writing the binding yourself. OCaml's opam ecosystem is smaller than npm and every package in it is already typed.
The Exotic Corners of the Type System
The far end of OCaml's type system did not make the trip, and this is where the two genuinely diverge.
(* OCaml has GADTs: a constructor can constrain the type parameter, so each branch of a match knows a different concrete type. *) type _ expression = | Int : int -> int expression | Bool : bool -> bool expression | Add : int expression * int expression -> int expression let rec evaluate : type a. a expression -> a = function | Int value -> value | Bool value -> value | Add (left, right) -> evaluate left + evaluate right let () = Printf.printf "%d\n" (evaluate (Add (Int 3, Int 4)))
// ReScript has no GADTs. The same interpreter needs one // result type and a runtime discrimination. type rec expression = Int(int) | Add(expression, expression) let rec evaluate = expression => switch expression { | Int(value) => value | Add(left, right) => evaluate(left) + evaluate(right) } Console.log(evaluate(Add(Int(3), Int(4))))
GADTs let a constructor refine the type parameter, so Int produces an int expression and Bool a bool expression, and the type a. annotation lets one function return a different type per branch — which is how typed interpreters and heterogeneous structures get written in OCaml. ReScript has no GADTs, so the ReScript column has to drop the Bool case entirely and return one type. Also absent: the let* binding operators, effect handlers, and objects. For most application code none of these come up; for a compiler or a proof-adjacent library they come up constantly.
Why Not TypeScript
The Type System Is Sound
This is the argument, and it is the one thing TypeScript cannot answer.
(* OCaml's type system is sound: a well-typed program does not go wrong, and there is no escape hatch in ordinary use. *) let describe (value : int) = "the number " ^ string_of_int value let () = print_endline (describe 42)
// ReScript's is sound too. There is no "any", no "as" // assertion, and no way to claim a type you do not have. let describe = (value: int) => "the number " ++ Js.Int.toString(value) Console.log(describe(42)) // TypeScript would accept this and fail at runtime: // const value = "42" as unknown as number; // value.toFixed(2) // ReScript has nothing to write in that position.
TypeScript is deliberately unsound: any, as assertions and untyped data at the boundary all let a value carry a type it does not have, and since types are erased with nothing checked, the mismatch surfaces as undefined rather than an error. That was the right call for describing fifteen years of existing JavaScript, and it means a TypeScript type is a claim rather than a fact. ReScript has no any and no assertion operator — the only place unsoundness can enter is an external binding you wrote, which is a small, greppable surface rather than a property of the language.
Exhaustiveness Without Asking
TypeScript can check exhaustiveness; the difference is that you have to remember to ask, in every switch, by hand.
(* Exhaustiveness is automatic and unconditional. Delete a branch and the compiler names the constructor you have not handled. *) type status = Pending | Active | Closed let describe = function | Pending -> "waiting" | Active -> "running" | Closed -> "finished" let () = List.iter (fun status -> print_endline (describe status)) [ Pending; Active; Closed ]
// The same guarantee, and no "never" trick to remember. type status = Pending | Active | Closed let describe = status => switch status { | Pending => "waiting" | Active => "running" | Closed => "finished" } Js.Array2.forEach([Pending, Active, Closed], status => Console.log(describe(status)) )
ReScript checks exhaustiveness the way OCaml does — automatically, on every switch, with an error naming the unhandled constructor. TypeScript's equivalent requires assigning the narrowed value to a never-typed variable in the default branch of each switch, which works and is idiomatic and is opt-in per site. Forget it once and adding a case to a union silently changes behavior instead of failing the build. For a codebase built around variants, that difference compounds.
And the Honest Case Against
Both columns print the same sentence and neither is doing anything interesting; the row exists to say the thing a page about a language rarely says.
(* Everything above argues for ReScript. This row is the other side, because the decision is not only about the type system. *) let () = print_endline "all four are reasons, and none is about the type system"
// ReScript's real costs, stated plainly: // - your colleagues probably do not read it // - every npm package's types are written for // TypeScript, and a binding is your job // - hiring, tutorials and Stack Overflow assume TS // - the JavaScript output is excellent, but the // debugging story crosses a compilation boundary Console.log("all four are reasons, and none is about the type system")
Everything else in this section is an argument for ReScript, and it is a good argument — the type system genuinely is better than TypeScript's in the ways that matter to someone coming from OCaml. The reasons to choose TypeScript anyway are real and are all social: your team reads it, every npm package ships .d.ts files written for it, and the hiring pool and the documentation assume it. If those do not bind — a solo project, a team already sold on ML, or a frontend for an OCaml backend — ReScript is the stronger tool. And if the deciding factor is wanting to keep writing OCaml itself, Melange is the option this page has been pointing at throughout: real OCaml, real dune and opam, compiled to the same readable JavaScript.
Living Alongside TypeScript
The practical answer to "my team already has a TypeScript codebase" — adoption does not have to be all or nothing.
(* An OCaml library is consumed from OCaml. Exposing it to another language means a C FFI on both sides. *) type person = { name : string; age : int } let describe person = Printf.sprintf "%s is %d" person.name person.age let () = print_endline (describe { name = "Ada"; age = 36 })
// @genType emits a .d.ts file beside the JavaScript, so // TypeScript callers get real types for this module and // the adoption can be file by file. @genType type person = {name: string, age: int} @genType let describe = person => person.name ++ " is " ++ Js.Int.toString(person.age) Console.log(describe({name: "Ada", age: 36}))
@genType generates TypeScript declaration files for the annotated values, so a .res module can be imported from TypeScript with accurate types and no hand-written bindings. That makes incremental adoption real: one module at a time, with the boundary checked in both directions. It is also the answer to the strongest practical objection on the previous row — you do not have to convince the whole team at once. Nothing equivalent exists for OCaml consumed from another language; there the boundary is a C FFI in both directions.