PONY λ M2 Modula-2

Haskell.CodeCompared.To/ReScript

An interactive executable cheatsheet comparing Haskell and ReScript

GHC 9.12 ReScript 12.3
Output & Basics
Hello, World
main :: IO () main = putStrLn "Hello, World!"
Js.log("Hello, World!")
Haskell requires a named main :: IO () entry point that the runtime calls once. ReScript has no such requirement — a source file compiles to an ordinary JavaScript module, and top-level statements just run in order when that module loads, with no wrapping function at all.
Sequencing multiple outputs
main :: IO () main = do putStrLn "First" putStrLn "Second" putStrLn "Third"
Js.log("First") Js.log("Second") Js.log("Third")
Haskell needs do-notation to sequence IO actions, since it desugars to chained monadic binds. ReScript is strict, so top-level statements simply execute one after another in the order written — no monad or block syntax required for plain sequencing, matching ordinary JavaScript's execution model.
Printing a value for inspection
main :: IO () main = print (Just [1, 2, 3])
Js.log(Some([1, 2, 3]))
Haskell's print uses the Show typeclass, deriving a textual representation for (almost) any value automatically. ReScript has no Show-equivalent typeclass at all — Js.log hands the value straight to JavaScript's console formatter, which does a reasonable job on plain data but has no notion of a derivable, customizable "show" instance the way Haskell does.
Values & Bindings
Let bindings
main :: IO () main = let radius = 5.0 area = pi * radius * radius in print area
let radius = 5.0 let area = 3.14159 *. radius *. radius Js.log(area)
Haskell's let ... in introduces bindings scoped to the following expression. ReScript's top-level let needs no explicit in — each binding is simply available to every subsequent line, matching JavaScript's const more closely than Haskell's let.
Immutable by default in both languages
main :: IO () main = let count = 0 -- count = 1 below would be a NEW binding shadowing the old one, -- not a mutation — Haskell has no assignment operator at all: in print count
let count = 0 // count = 1 below would just SHADOW this binding in the same scope — // ReScript's "let" has no assignment form at all, only rebinding: Js.log(count)
Both languages default every binding to immutable, with no assignment operator for a plain let. Getting real mutation in ReScript means reaching for a ref cell (next row) — closer to Haskell's IORef than to JavaScript's freely-reassignable let.
Opting in to mutation with ref
import Data.IORef main :: IO () main = do ref <- newIORef 0 modifyIORef ref (+1) value <- readIORef ref print value
let counter = ref(0) counter := counter.contents + 1 Js.log(counter.contents)
Haskell reaches for IORef and threads it explicitly through IO with newIORef/modifyIORef/readIORef. ReScript's ref is a direct structural cousin: ref(0) allocates a mutable cell, := updates it, and .contents reads it — the same "explicit mutable box" idea, just without a surrounding IO monad to sequence it through.
Types & Inference
Both infer types via Hindley-Milner
double :: Int -> Int double x = x * 2 main :: IO () main = print (double 21)
let double = (x) => x * 2 Js.log(double(21))
Both languages use a Hindley-Milner-style inference algorithm inherited from the ML family, so explicit type signatures are usually optional. ReScript's double here has no annotation at all — the compiler infers int => int from how x is used, exactly as Haskell would infer Int -> Int.
Explicit type annotations
describe :: Int -> String describe n | n > 0 = "positive" | otherwise = "non-positive" main :: IO () main = putStrLn (describe 5)
let describe = (n: int): string => if n > 0 { "positive" } else { "non-positive" } Js.log(describe(5))
Haskell places a function's type signature on its own line above the definition, using ::. ReScript annotates parameters and return types inline, using :, closer to TypeScript than to Haskell's separate signature line — both are optional whenever inference can work it out alone.
int and float are distinct types, with distinct operators
main :: IO () main = print (fromIntegral (3 :: Int) + (2.5 :: Double))
let wholeNumber = 3 let fraction = 2.5 Js.log(Js.Int.toFloat(wholeNumber) +. fraction)
Haskell's numeric literals are polymorphic over the Num typeclass, and crossing between representations goes through fromIntegral. ReScript has no numeric-tower typeclass either, but it goes a step further at the syntax level: int and float even use different arithmetic operators (+ versus +., * versus *.), so mixing them is caught immediately, not just at the type level.
Strings
String concatenation
main :: IO () main = putStrLn ("Hello, " ++ "World!")
Js.log("Hello, " ++ "World!")
The operator is spelled identically — ++ — in both languages, though the meaning differs: Haskell's ++ is the general list-concatenation operator (since String is really [Char]), while ReScript's ++ is dedicated purely to string concatenation, since ReScript strings are genuine JavaScript strings, not character lists.
String interpolation
import Text.Printf (printf) main :: IO () main = do let name = "Ada" age = 36 :: Int printf "%s is %d\n" name age
let name = "Ada" let age = 36 Js.log(`${name} is ${Js.Int.toString(age)}`)
Haskell has no native interpolation — the idiomatic route is Text.Printf with positional format specifiers. ReScript reuses JavaScript's template literals directly: backtick strings with \${ } placeholders — but note ReScript will not implicitly stringify a non-string value inside one, so age needs an explicit conversion first.
A string is a list of characters — or a real JS string
main :: IO () main = print (length "hello")
Js.log(Js.String2.length("hello"))
Haskell's String is literally a type alias for [Char], so ordinary list functions like length apply directly. ReScript's string is a genuine, opaque JavaScript string with no list-of-characters representation underneath, so string-specific operations live in the Js.String2 module rather than the list/array functions.
Lists vs. Arrays
Haskell lists become ReScript arrays, not ReScript lists
main :: IO () main = print [1, 2, 3, 4, 5]
Js.log([1, 2, 3, 4, 5])
ReScript does have a list type — a singly-linked list matching Haskell's representation exactly, spelled list{1, 2, 3} — but idiomatic ReScript reaches for array instead, since arrays compile directly to JavaScript arrays with no conversion cost at the JS interop boundary. This page follows that convention: the closest match in spirit is list, but the closest match in practice is array.
map and filter
main :: IO () main = let doubledEvens = map (* 2) (filter even [1, 2, 3, 4, 5, 6]) in print doubledEvens
let doubledEvens = [1, 2, 3, 4, 5, 6] ->Js.Array2.filter(n => mod(n, 2) == 0) ->Js.Array2.map(n => n * 2) Js.log(doubledEvens)
Both languages call these operations map and filter, in Haskell as unqualified Prelude functions and in ReScript as Js.Array2 module functions threaded through the -> pipe operator — reading data-first, left to right, rather than Haskell's function-first nested-call style.
Folding an array
main :: IO () main = print (foldl (+) 0 [1, 2, 3, 4])
let total = [1, 2, 3, 4]->Js.Array2.reduce((accumulator, n) => accumulator + n, 0) Js.log(total)
Haskell's foldl and ReScript's Js.Array2.reduce take the same pieces — initial accumulator, then a two-argument function — but ReScript inherits JavaScript's reduce naming and argument order (accumulator first in the callback) rather than Haskell's foldl naming.
Maybe vs. Option
Maybe becomes option, Just/Nothing become Some/None
safeDivide :: Int -> Int -> Maybe Int safeDivide _ 0 = Nothing safeDivide x y = Just (x `div` y) main :: IO () main = print (safeDivide 10 0)
let safeDivide = (x, y) => if y == 0 { None } else { Some(x / y) } Js.log(safeDivide(10, 0))
Haskell's Maybe a (Just x / Nothing) and ReScript's option<'a> (Some(x) / None) are the same idea with renamed constructors — both replace null with an explicit, compiler-enforced "value or absence" type, and both are common enough to be considered the idiomatic default in their language.
Skipping the absence case is a compile error in both
safeDivide :: Int -> Int -> Maybe Int safeDivide _ 0 = Nothing safeDivide x y = Just (x `div` y) main :: IO () main = case safeDivide 10 0 of Just value -> print value Nothing -> putStrLn "no result"
let safeDivide = (x, y) => if y == 0 { None } else { Some(x / y) } switch safeDivide(10, 0) { | Some(value) => Js.log(value) | None => Js.log("no result") }
Neither compiler lets you treat an option/Maybe as if it were the bare inner value. A switch/case covering both Some/Just and None/Nothing — or an explicit combinator that forces a default, like ReScript's Belt.Option.getWithDefault — is the only way out in either language.
Interop with JavaScript null — a problem Haskell never has
-- Haskell has nothing analogous to interop with a foreign runtime's -- null/undefined — every Haskell value it can see is already a real, -- type-checked Haskell value: main :: IO () main = print (Just "hello")
// A JS API might hand ReScript a value that is null or undefined — // Js.Nullable.t<'a> models exactly that boundary case, and // toOption converts it into a safe, poison-free option: let maybeValue: Js.Nullable.t<string> = Js.Nullable.return("hello") Js.log(Js.Nullable.toOption(maybeValue))
This is a problem category that simply does not exist for Haskell: since Haskell has no interop boundary with a language that has null, its Maybe only ever needs to represent absence that the Haskell program itself created. ReScript, compiling to and interoperating with JavaScript, needs the separate Js.Nullable.t<'a> type to safely represent a value that might arrive as null or undefined from the JS side, with Js.Nullable.toOption converting it into ReScript's own safe option.
Algebraic Data Types vs. Variants
Modeling "one of several shapes"
data Shape = Circle Double | Rectangle Double Double area :: Shape -> Double area (Circle radius) = pi * radius * radius area (Rectangle width height) = width * height main :: IO () main = print (map area [Circle 5.0, Rectangle 3.0 4.0])
type shape = | Circle(float) | Rectangle(float, float) let area = (shape) => switch shape { | Circle(radius) => 3.14159 *. radius *. radius | Rectangle(width, height) => width *. height } Js.log([Circle(5.0), Rectangle(3.0, 4.0)]->Js.Array2.map(area))
Haskell's algebraic data types and ReScript's variants are effectively the same feature under a different name — both declare a closed set of tagged, optionally payload-carrying alternatives, and both are deconstructed with pattern matching the compiler checks for exhaustiveness. Both languages inherit this feature from the same ML lineage.
Enum-like variants with no payload
data Direction = North | South | East | West describe :: Direction -> String describe North = "Heading north" describe South = "Heading south" describe East = "Heading east" describe West = "Heading west" main :: IO () main = putStrLn (describe East)
type direction = North | South | East | West let describe = (dir) => switch dir { | North => "Heading north" | South => "Heading south" | East => "Heading east" | West => "Heading west" } Js.log(describe(East))
A payload-free algebraic data type in Haskell and a payload-free variant in ReScript both compile down to a small, compiler-checked closed set of tags — a near-mechanical translation, right down to the constructor names.
Recursive data types
data Tree = Leaf | Node Tree Int Tree sumTree :: Tree -> Int sumTree Leaf = 0 sumTree (Node left value right) = sumTree left + value + sumTree right main :: IO () main = print (sumTree (Node (Node Leaf 1 Leaf) 2 (Node Leaf 3 Leaf)))
type rec tree = | Leaf | Node(tree, int, tree) let rec sumTree = (tree) => switch tree { | Leaf => 0 | Node(left, value, right) => sumTree(left) + value + sumTree(right) } Js.log(sumTree(Node(Node(Leaf, 1, Leaf), 2, Node(Leaf, 3, Leaf))))
A recursive sum type referencing itself — the classic binary tree — translates almost line for line, though ReScript requires the explicit type rec keyword for a self-referencing type (mirroring the let rec it also requires for the recursive function), where Haskell's data declarations may always refer to themselves with no extra marker.
Pattern Matching
case versus switch
describe :: Int -> String describe n = case n of 0 -> "zero" n | n > 0 -> "positive" _ -> "negative" main :: IO () main = putStrLn (describe (-5))
let describe = (n) => switch n { | 0 => "zero" | n if n > 0 => "positive" | _ => "negative" } Js.log(describe(-5))
Haskell's case ... of and ReScript's switch { } read as dialects of the same construct: patterns tried top to bottom, an if-guard attachable to a pattern (Haskell reuses | here, ReScript borrows JavaScript's if keyword), and a wildcard _ catch-all.
Both compilers check exhaustiveness
-- GHC warns here (with -Wincomplete-patterns) since the -- negative case is missing entirely: classify :: Int -> String classify 0 = "zero" classify n | n > 0 = "positive" main :: IO () main = putStrLn (classify 5)
let classify = (n) => switch n { | 0 => "zero" | n if n > 0 => "positive" // ReScript's compiler emits a WARNING here too — "this pattern-match // is not exhaustive" — if the negative case were missing: | _ => "negative" } Js.log(classify(5))
Both compilers statically analyze whether a pattern match covers every possible case and warn when it does not, catching a whole class of runtime crash before the program ever executes — a shared strength both languages inherit from taking algebraic data types seriously.
Destructuring a tuple
main :: IO () main = let point = (3, 4) (x, y) = point in print (x + y)
let point = (3, 4) let (x, y) = point Js.log(x + y)
Tuple destructuring is essentially identical syntax in both languages — a parenthesized, comma-separated pattern on the left of a binding, matched positionally against the tuple's shape.
Records
Defining a record
data Point = Point { x :: Int, y :: Int } deriving (Show) main :: IO () main = let point = Point { x = 3, y = 4 } in print (x point, y point)
type point = { x: int, y: int } let point = { x: 3, y: 4 } Js.log((point.x, point.y))
Both define a named product type with labeled fields, accessed with dot syntax. ReScript's record fields are separated by , rather than Haskell's field-accessor-function style, and — unlike Haskell — ReScript infers the record's type from the fields used at the call site, so the type point = { ... } declaration is sometimes entirely optional.
Record spread-update syntax
data Point = Point { x :: Int, y :: Int } deriving (Show) main :: IO () main = let point = Point { x = 3, y = 4 } moved = point { y = 99 } in print moved
type point = { x: int, y: int } let point = { x: 3, y: 4 } let moved = { ...point, y: 99 } Js.log(moved)
Haskell's point { y = 99 } and ReScript's { ...point, y: 99 } both produce a new record with one field replaced and every other field copied automatically — ReScript's syntax borrows JavaScript's object-spread ... directly, rather than Haskell's dedicated record-update braces.
Functions & Currying
A multi-argument function is NOT automatically curried
add :: Int -> Int -> Int add x y = x + y main :: IO () main = let addFive = add 5 in print (addFive 10)
let add = (x, y) => x + y // addFive below is a COMPILE ERROR — "this function takes 2 arguments, // but is called with just 1": // let addFive = add(5) Js.log(add(5, 10))
This is a genuine surprise for a Haskeller expecting ML-family defaults. Older BuckleScript/ReasonML curried every function automatically, but modern ReScript (v11+, including the 12.3 this page targets) compiles multi-argument functions to genuine fixed-arity JavaScript functions for cleaner, more predictable JS interop and performance — calling add(5) alone is a compile error, not a partial application. Partial application still exists, but only through explicitly-nested single-argument lambdas (next row).
Getting Haskell-style currying back: nest single-argument lambdas
add :: Int -> Int -> Int add x y = x + y main :: IO () main = let addFive = add 5 in print (addFive 10)
let add = (x) => (y) => x + y let addFive = add(5) Js.log(addFive(10))
Writing add as a chain of one-argument functions — (x) => (y) => x + y, exactly what Haskell's Int -> Int -> Int desugars to under the hood — restores the partial-application behavior a Haskeller expects, since add(5) now genuinely returns a single-argument function rather than being a call with a missing argument. The tradeoff: every call site must also use single-argument application, add(5)(10) rather than add(5, 10), so this style is not idiomatic ReScript outside of contexts that specifically want Haskell-like partial application.
Anonymous functions (lambdas)
main :: IO () main = print (map (\x -> x * x) [1, 2, 3, 4])
Js.log([1, 2, 3, 4]->Js.Array2.map(x => x * x))
Haskell spells a lambda \x -> ... (the backslash evoking the Greek letter lambda). ReScript spells the same idea x => ..., borrowing JavaScript's arrow-function syntax directly — the same syntax ReScript uses for every named function, since let add = (x, y) => x + y is simply an arrow function bound to a name.
Recursion requires `let rec` in ReScript
factorial :: Integer -> Integer factorial 0 = 1 factorial n = n * factorial (n - 1) main :: IO () main = print (factorial 10)
let rec factorial = (n) => if n <= 0 { 1 } else { n * factorial(n - 1) } Js.log(factorial(10))
An ordinary Haskell top-level binding may always refer to itself — no special keyword needed. ReScript requires the explicit rec keyword (let rec) before a function can call itself; without it, the function's own name is not in scope inside its own body, and the compiler rejects the self-reference outright.
Composition vs. the -> Pipe
Point-free composition versus the data-first pipe
main :: IO () main = let addOneThenDouble = (* 2) . (+ 1) in print (addOneThenDouble 5)
let addOne = (x) => x + 1 let double = (x) => x * 2 let result = 5->addOne->double Js.log(result)
Haskell's . glues function VALUES together, right-to-left, without ever mentioning the argument — genuinely point-free style. ReScript has no equivalent composition operator; instead its idiomatic tool is the -> pipe operator, which threads a concrete VALUE through a sequence of function calls left to right, closer to method chaining than to function composition.
Chaining transformations with the pipe operator
main :: IO () main = let numbers = [1, 2, 3, 4, 5] result = filter even (map (* 2) numbers) in print result
let numbers = [1, 2, 3, 4, 5] let result = numbers ->Js.Array2.map(n => n * 2) ->Js.Array2.filter(n => mod(n, 2) == 0) Js.log(result)
A Haskell pipeline nests function calls and must be read from the inside out (or introduces a fresh temporary variable per step, or uses Data.Function's rarely-used & operator). ReScript's -> makes the equivalent pipeline read top-to-bottom in execution order, its single most idiomatic feature for exactly this kind of data transformation.
Control Flow
if is an expression in both languages
main :: IO () main = let n = 5 message = if n > 0 then "positive" else "non-positive" in putStrLn message
let n = 5 let message = if n > 0 { "positive" } else { "non-positive" } Js.log(message)
In both languages if produces a value rather than merely branching, and else is mandatory in both, since without it there would be no value on the false path. ReScript additionally requires braces around each branch's body, where Haskell needs none.
No native loop that mutates — recursion is idiomatic in both
countdown :: Int -> IO () countdown 0 = putStrLn "liftoff!" countdown n = do print n countdown (n - 1) main :: IO () main = countdown 3
let rec countdown = (n) => if n == 0 { Js.log("liftoff!") } else { Js.log(n) countdown(n - 1) } countdown(3)
ReScript does compile for/while loops to genuine JavaScript loops (unlike Haskell, which has neither), but idiomatic ReScript still favors recursion and higher-order array functions over an imperative loop for anything beyond raw iteration — closer to Haskell's culture than to typical JavaScript style.
Laziness vs. Strictness
Infinite lists exist only because Haskell is lazy
main :: IO () main = print (take 5 [1..])
// No infinite-sequence constructor exists at all — a bounded literal // is how ReScript gets "the first five numbers" directly: let numbers = [1, 2, 3, 4, 5] Js.log(numbers)
Haskell's [1..] is an ordinary list that only works because Haskell is lazy, generating each element on demand as take asks for it. ReScript is strict, like the JavaScript it compiles to, and has no way to construct an infinite sequence at all, so a bounded literal is simply how ReScript gets "the first five numbers" directly.
No thunks: every binding is fully evaluated
main :: IO () main = do let expensive = 2 ^ 20 -- not computed until actually demanded putStrLn "before use" print expensive
// ReScript has no thunks — "expensive" is computed right here, on // this line, immediately, not deferred until first use: let expensive = Js.Math.pow_float(~base=2.0, ~exp=20.0) Js.log("before use") Js.log(expensive)
In Haskell, every binding is lazy unless forced otherwise — expensive is not actually computed until print demands it, and repeated use of the same thunk is cached automatically. ReScript has no thunks at all: expensive is computed the moment its binding line runs, matching ordinary JavaScript's eager evaluation exactly.
Either vs. Exceptions-as-Variants
Either becomes a manually-modeled result variant
divide :: Int -> Int -> Either String Int divide _ 0 = Left "divide by zero" divide x y = Right (x `div` y) main :: IO () main = print (divide 10 0)
type result<'ok, 'error> = | Ok('ok) | Error('error) let divide = (x, y) => if y == 0 { Error("divide by zero") } else { Ok(x / y) } Js.log(divide(10, 0))
Haskell's Either String Int has no built-in ReScript equivalent shipped in the standard library the way option is — the idiomatic move is to declare your own two-case variant, commonly named result and shaped just like this (and, in fact, identical to Rust's Result). It is one extra step Haskell does not require, since Either already ships in base.
Exceptions declared and matched like variants
import Control.Exception data ValidationError = TooYoung | TooOld deriving (Show) instance Exception ValidationError validateAge :: Int -> IO Int validateAge age | age < 0 = throwIO TooYoung | age > 150 = throwIO TooOld | otherwise = return age main :: IO () main = do result <- try (validateAge (-1)) :: IO (Either ValidationError Int) case result of Left err -> putStrLn ("Validation failed: " ++ show err) Right age -> print age
exception TooYoung exception TooOld let validateAge = (age) => if age < 0 { raise(TooYoung) } else if age > 150 { raise(TooOld) } else { age } try { Js.log(validateAge(-1)) } catch { | TooYoung => Js.log("Validation failed: too young") | TooOld => Js.log("Validation failed: too old") }
Both languages let an exception carry a typed, named identity rather than a bare string: Haskell declares one via data plus an Exception instance, ReScript via the dedicated exception keyword. Both are then caught with pattern matching — Haskell's case over the Either that try produces, ReScript's catch { } block matching directly on the exception's constructors.
Type Classes vs. No Ad-hoc Polymorphism
Type classes have essentially no ReScript equivalent
class Describable a where describe :: a -> String data Dog = Dog instance Describable Dog where describe _ = "a dog" main :: IO () main = putStrLn (describe Dog)
type dog = Dog let describe = (_dog: dog) => "a dog" Js.log(describe(Dog))
Haskell resolves "which implementation for which type" through type classes, with the compiler automatically picking the right instance based on the type at the call site. ReScript has no type classes, no instance mechanism, and — unlike F#'s OO interfaces or Roc's structural where constraints — not even a well-established workaround; with only one type involved, as here, the ReScript side collapses to an ordinary, non-polymorphic function.
Ad-hoc polymorphism means separate, differently-named functions
class Printable a where toDisplay :: a -> String instance Printable Int where toDisplay n = show n instance Printable Double where toDisplay d = show d main :: IO () main = do putStrLn (toDisplay (42 :: Int)) putStrLn (toDisplay (2.5 :: Double))
let intToDisplay = (n: int) => Js.Int.toString(n) let floatToDisplay = (f: float) => Js.Float.toString(f) Js.log(intToDisplay(42)) Js.log(floatToDisplay(2.5))
Where Haskell dispatches toDisplay automatically based on the argument's type, ReScript has no shared name to dispatch through at all — the idiomatic answer is simply two differently-named functions, intToDisplay and floatToDisplay, resolved the ordinary way by which name the caller writes. There is no attempt to fake type-directed dispatch; ReScript's culture, coming from the JS ecosystem, is comfortable with explicit, differently-named functions instead.
No deriving at all — structural equality is just built in
data Point = Point Int Int deriving (Eq, Show) main :: IO () main = print (Point 1 2 == Point 1 2)
type point = Point(int, int) Js.log(Point(1, 2) == Point(1, 2))
Haskell requires an explicit deriving (Eq) clause to opt a type into structural equality (mechanically, an automatically generated type class instance) — with no deriving clause, == would not even compile. ReScript needs no opt-in step at all: structural equality via == works on any variant or record out of the box, with no deriving-equivalent keyword to remember, because there is no type-class machinery underneath it to opt into in the first place.
Gotchas for Haskell Developers
Integer division truncates; float division needs `/.`
main :: IO () main = print (7 `div` 2)
// ReScript's "/" on two ints truncates toward zero, matching Haskell's // "div" exactly — but writing "7.0 /. 2.0" is required for a fractional // result, since "/" and "/." are genuinely different operators: Js.log(7 / 2)
Haskell's div is a named function for integer division that truncates toward negative infinity. ReScript reuses the ordinary / symbol for integer division (truncating toward zero, matching JavaScript's Math.trunc behavior rather than Haskell's floor behavior on negative operands) and requires the entirely separate /. operator for float division — mixing them up is a compile error, not a silent bug.
Belt looks idiomatic but breaks in a plain browser context
-- Haskell's base library has no analogous "two standard libraries, -- pick carefully" split — Prelude and its common extensions are all -- meant to run anywhere GHC does: main :: IO () main = print (map (* 2) [1, 2, 3])
// Belt.Array.map would look equally idiomatic here, but Belt-module // output includes require() calls meant for a Node/bundler context — // it fails when the compiled JS is eval'd directly in a plain browser // page, which is exactly how this site's live "Run" button works. // Js.Array2 compiles to dependency-free JS and works everywhere: Js.log([1, 2, 3]->Js.Array2.map(n => n * 2))
ReScript effectively ships two overlapping standard libraries: Belt (richer, more ergonomic, the modern recommendation for real projects) and Js (a thinner, more direct mapping onto JavaScript's own built-ins). Compiled Belt output includes require() calls assuming a Node or bundler environment, which fails when eval'd directly in a browser page with no module loader — exactly the execution model this site's live run buttons use, which is why every example on this page sticks to Js.*.