Basics & Bindings
Hello, World
Elm renders a pure
main value; ReScript compiles to plain JavaScript and logs with Js.log — an ordinary side effect, allowed anywhere.module Main exposing (main)
import Html exposing (text)
main =
text "Hello, World!"
Js.log("Hello, World!") The compiled output is exactly
console.log("Hello, World!") — readable JavaScript a reviewer can diff, which is a core ReScript value. Elm's compiled output is a runtime plus generated code you are never expected to read; ReScript's is code you are expected to ship.Bindings & inference
Both compilers infer nearly everything. Elm annotates on a separate line; ReScript annotates inline — and both annotations are optional.
module Main exposing (main)
import Html exposing (text)
greeting : String
greeting =
"Hello from Elm"
answer =
42
main =
text (greeting ++ " " ++ String.fromInt answer)
let greeting: string = "Hello from ReScript"
let answer = 42
Js.log(greeting)
Js.log(answer) Same Hindley–Milner machinery under both hoods — ReScript is a direct descendant of OCaml, the same family Elm grew from. Type names go lowercase (
string, int), and a let binding is immutable in both languages.Mutation: the ref escape hatch
Elm has no mutation, full stop. ReScript is immutable by default but provides
ref cells — a one-field mutable box — when you opt in.module Main exposing (main)
import Html exposing (text)
counter : Int
counter =
0
incremented : Int
incremented =
counter + 1
main =
text (String.fromInt incremented)
let counter = ref(0)
counter := counter.contents + 1
counter := counter.contents + 1
Js.log(counter.contents) A
ref wears its mutability in the type — ref<int> — so nothing mutates silently; := writes and .contents reads. Idiomatic ReScript reaches for a ref about as often as idiomatic Elm reaches for a fold: rarely, deliberately.Numbers & Operators
Int and float have separate operators
Elm's
+ works on any number. ReScript inherits OCaml's split: +, -, *, / are for integers; floats use +., -., *., /. with a trailing dot.module Main exposing (main)
import Html exposing (text)
main =
text (String.fromInt (7 // 2) ++ " and " ++ String.fromFloat (7 / 2))
Js.log(7 / 2)
Js.log(7.0 /. 2.0)
Js.log(Js.Int.toFloat(7) /. 2.0) Integer
/ truncates (7 / 2 is 3, Elm's //), and mixing an int with a float is a type error in both languages — ReScript just also makes the operator carry the type. The dotted operators feel odd for a week and then become documentation: you always know which arithmetic you are reading.Strings
Concatenation & interpolation
ReScript keeps Elm's
++ for string concatenation — and adds the JavaScript template literal, backticks and ${} included.module Main exposing (main)
import Html exposing (text)
name : String
name =
"world"
main =
text ("Hello, " ++ name ++ "! " ++ String.fromInt (6 * 7))
let name = "world"
Js.log("Hello, " ++ name ++ "!")
Js.log(`Hello, ${name}! The answer is ${Js.Int.toString(6 * 7)}`) The interpolated form compiles to a real JavaScript template literal. One Elm-like restriction survives:
${} only accepts strings, so numbers still need Js.Int.toString — the compiler will not quietly coerce, exactly as ++ refuses non-strings in Elm.String functions, data-first
module Main exposing (main)
import Html exposing (text)
greeting : String
greeting =
"hello world"
main =
text (String.toUpper greeting ++ " / " ++ String.fromInt (String.length greeting))
let greeting = "hello world"
Js.log(Js.String2.toUpperCase(greeting))
Js.log(Js.String2.length(greeting))
Js.log(Js.String2.replace(greeting, "world", "ReScript")) The
Js.String2 module wraps JavaScript's own string methods with the subject as the first argument (that is the "2"), which is what makes them flow through ReScript's data-first pipe in the next section. Note the working set is the JS standard library itself — nothing new to learn if you ever wrote JavaScript, nothing Elm-specific to miss.Functions
Definitions are arrows
Elm's equation style becomes the JavaScript arrow — named functions are just
let bindings holding an arrow.module Main exposing (main)
import Html exposing (text)
add : Int -> Int -> Int
add first second =
first + second
main =
text (String.fromInt (add 2 3))
let add = (first, second) => first + second
Js.log(add(2, 3)) Parentheses and commas replace Elm's whitespace application:
add(2, 3), not add 2 3. The inferred type is written (int, int) => int — a two-argument function, not Elm's chain of one-argument functions, which the next concept makes concrete.No automatic currying
Since version 11, ReScript functions are uncurried by default: they take all arguments at once, and partial application is an explicit lambda.
module Main exposing (main)
import Html exposing (text)
add : Int -> Int -> Int
add first second =
first + second
addTen : Int -> Int
addTen =
add 10
main =
text (String.fromInt (addTen 5))
let add = (first, second) => first + second
let addTen = second => add(10, second)
Js.log(addTen(5)) ReScript gave up currying deliberately: uncurried calls compile to plain JavaScript function calls (no runtime currying helpers), keep arity errors honest, and make the output readable. The cost is Elm's effortless
add 10 — you write the wrapper lambda yourself. It is the single biggest day-one adjustment for an Elm developer.Anonymous functions
module Main exposing (main)
import Html exposing (text)
main =
text (Debug.toString (List.map (\number -> number * 3) [ 1, 2, 3 ]))
Js.log(Js.Array2.map([1, 2, 3], number => number * 3)) Elm's
\number -> is the arrow number => — the same lambda JavaScript writes, because it compiles to exactly that. Single parameters need no parentheses.Recursion needs `rec`
module Main exposing (main)
import Html exposing (text)
factorial : Int -> Int
factorial number =
if number <= 1 then
1
else
number * factorial (number - 1)
main =
text (String.fromInt (factorial 5))
let rec factorial = number =>
if number <= 1 {
1
} else {
number * factorial(number - 1)
}
Js.log(factorial(5)) Like its OCaml parent (and F#), ReScript requires
let rec before a binding may call itself. Note the braces: ReScript's if is still an expression that returns a value, as in Elm, but it wears JavaScript's block syntax rather than then/else keywords.The Pipe Operator
The pipe points the other way in
ReScript's pipe is
->, and like Elixir's it feeds the first argument — the natural fit for uncurried, data-first functions.module Main exposing (main)
import Html exposing (text)
main =
[ 1, 2, 3, 4, 5 ]
|> List.map (\number -> number * 2)
|> List.filter (\number -> number > 4)
|> List.sum
|> String.fromInt
|> text
let total =
[1, 2, 3, 4, 5]
->Js.Array2.map(number => number * 2)
->Js.Array2.filter(number => number > 4)
->Js.Array2.reduce((sum, number) => sum + number, 0)
Js.log(total) The pipeline reads identically to Elm's, but
value->func(argument) means func(value, argument) — first position, not last. That is why every Js.*2 module takes its subject first. Losing currying and switching pipe direction are the same design decision seen from two sides: data-first works without partial application.Arrays, Lists & Tuples
Arrays are JavaScript arrays
ReScript's default sequence type
[1, 2, 3] IS the JavaScript array — contiguous, zero-cost to pass to JS, and mutable.module Main exposing (main)
import Html exposing (text)
main =
text (Debug.toString [ 10, 20, 30 ])
let numbers = [10, 20, 30]
numbers[0] = 99
Js.log(numbers)
Js.log(Js.Array2.length(numbers)) This is the sharpest data-model difference: Elm's
List is immutable and its Array is a persistent structure, while ReScript's bracket literal is a live JS array that numbers[0] = 99 mutates in place. The types still protect you — every element must be the same type — but the immutability guarantee is gone unless you choose the next concept's list.list{} is your List
When you want Elm-style immutable cons lists, ReScript has them under the
list{…} literal, spread syntax standing in for cons.module Main exposing (main)
import Html exposing (text)
describe : List Int -> String
describe numbers =
case numbers of
[] ->
"empty"
first :: rest ->
String.fromInt first ++ " then " ++ Debug.toString rest
main =
text (describe [ 1, 2, 3 ])
let numbers = list{1, 2, 3}
let described = switch numbers {
| list{} => "empty"
| list{first, ..._rest} => "starts with " ++ Js.Int.toString(first)
}
Js.log(described) A
list is the same immutable singly-linked structure as Elm's List, with list{head, ...tail} as both constructor and pattern — Elm's :: in spread clothing. In practice ReScript code defaults to arrays for JS interop and reaches for list when persistent semantics matter; Elm makes the opposite default.Tuples
module Main exposing (main)
import Html exposing (text)
point : ( Int, String )
point =
( 1, "one" )
main =
text (String.fromInt (Tuple.first point) ++ " is " ++ Tuple.second point)
let point = (1, "one")
let (number, name) = point
Js.log2(number, name) Same parenthesized tuples, destructured in a
let exactly as Elm allows — and with no pair-and-triple limit. A ReScript tuple compiles to a plain JavaScript array, which is the recurring theme: familiar ML semantics, transparent JS representation.Records
Records need a declaration
Elm records are structural — any literal works. ReScript records are nominal: declare the type first, then the literal is checked against it.
module Main exposing (main)
import Html exposing (text)
type alias Person =
{ name : String
, age : Int
}
person : Person
person =
{ name = "Ada", age = 36 }
main =
text (person.name ++ " is " ++ String.fromInt person.age)
type person = {name: string, age: int}
let person = {name: "Ada", age: 36}
Js.log(person.name ++ " is " ++ Js.Int.toString(person.age)) The literal finds its type by field names, dot access works the same, and a typo in a field name is a compile error in both languages. A ReScript record compiles to a plain JavaScript object —
{name: "Ada", age: 36}, literally — so records cross the JS boundary with no conversion at all.Record update is a spread
Elm's
{ person | age = 37 } becomes JavaScript's spread: {...person, age: 37}. Same copy-with-changes meaning, JS spelling.module Main exposing (main)
import Html exposing (text)
type alias Person =
{ name : String
, age : Int
}
person : Person
person =
{ name = "Ada", age = 36 }
older : Person
older =
{ person | age = 37 }
main =
text (older.name ++ " is now " ++ String.fromInt older.age)
type person = {name: string, age: int}
let person = {name: "Ada", age: 36}
let older = {...person, age: 37}
Js.log(older.name ++ " is now " ++ Js.Int.toString(older.age))
Js.log(person.name ++ " is still " ++ Js.Int.toString(person.age)) Both expressions allocate a new record and leave the original untouched, as the second log shows. The spread cannot add fields the type does not declare — the same rule as Elm's update syntax, enforced by the same kind of compiler.
Variants
Custom types are variants
Elm's custom types are ReScript's variants — constructors with parenthesized payloads, matched exhaustively.
module Main exposing (main)
import Html exposing (text)
type Shape
= Circle Float
| Rectangle Float Float
area : Shape -> Float
area shape =
case shape of
Circle radius ->
3.14159 * radius * radius
Rectangle width height ->
width * height
main =
text (String.fromFloat (area (Rectangle 3 4)))
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(area(Rectangle(3.0, 4.0))) Constructor payloads use call syntax —
Rectangle(3.0, 4.0) — instead of Elm's curried application, and the float math wears its dots. Everything else is home turf: closed set of constructors, compiler-checked matching, and the same design instinct to make impossible states unrepresentable.Type parameters
module Main exposing (main)
import Html exposing (text)
type Tree a
= Leaf
| Node (Tree a) a (Tree a)
size : Tree a -> Int
size tree =
case tree of
Leaf ->
0
Node left _ right ->
1 + size left + size right
main =
text (String.fromInt (size (Node (Node Leaf 1 Leaf) 2 (Node Leaf 3 Leaf))))
type rec tree<'value> =
| Leaf
| Node(tree<'value>, 'value, tree<'value>)
let rec size = tree =>
switch tree {
| Leaf => 0
| Node(left, _, right) => 1 + size(left) + size(right)
}
Js.log(size(Node(Node(Leaf, 1, Leaf), 2, Node(Leaf, 3, Leaf)))) Elm's lowercase type variable
a becomes the tick-marked 'value in angle brackets — the OCaml heritage showing through — and a self-referential type takes type rec, just as a self-calling function takes let rec. Inference fills the parameters in exactly as Elm does.Pattern Matching
case…of becomes switch
module Main exposing (main)
import Html exposing (text)
describe : List Int -> String
describe numbers =
case numbers of
[] ->
"empty"
[ single ] ->
"one: " ++ String.fromInt single
first :: _ ->
"starts with " ++ String.fromInt first
main =
text (describe [ 7, 8, 9 ])
let describe = numbers =>
switch numbers {
| list{} => "empty"
| list{single} => "one: " ++ Js.Int.toString(single)
| list{first, ..._} => "starts with " ++ Js.Int.toString(first)
}
Js.log(describe(list{7, 8, 9})) ReScript's
switch is Elm's case wearing JavaScript's keyword: patterns on the left of =>, exhaustiveness checked by the compiler, no fallthrough, every branch an expression. A missing constructor case is flagged just as loudly as in Elm.Guards with if
ReScript patterns can carry an
if guard — the feature Elm deliberately left out in favor of nested conditionals.module Main exposing (main)
import Html exposing (text)
describe : Int -> String
describe number =
if number == 0 then
"zero"
else if number < 0 then
"negative"
else
"positive"
main =
text (describe (-4))
let describe = number =>
switch number {
| 0 => "zero"
| value if value < 0 => "negative"
| _ => "positive"
}
Js.log(describe(-4)) A guarded branch runs its boolean after the pattern binds — and, exactly as in F#, guards cost you some of the exhaustiveness analysis Elm keeps by not having them: the compiler cannot see that the guards cover everything, so the final
_ stays mandatory.option & result
Maybe is option
Maybe a is option<'a>: Just becomes Some, Nothing becomes None — and there is still no null in your own code.module Main exposing (main)
import Html exposing (text)
ages : List ( String, Int )
ages =
[ ( "Ada", 36 ) ]
lookup : String -> Maybe Int
lookup name =
ages
|> List.filter (\( key, _ ) -> key == name)
|> List.head
|> Maybe.map Tuple.second
main =
text (Debug.toString ( lookup "Ada", lookup "Grace" ))
let lookupAge = name =>
switch name {
| "Ada" => Some(36)
| _ => None
}
let describe = name =>
switch lookupAge(name) {
| Some(age) => name ++ " is " ++ Js.Int.toString(age)
| None => name ++ " is unknown"
}
Js.log(describe("Ada"))
Js.log(describe("Grace")) Functions that can come up empty return
option, matched with Some/None exactly like Just/Nothing. The compiled representation is worth knowing: None becomes JavaScript undefined and Some(36) becomes bare 36 — zero-cost on the wire, type-safe on your side of it.Result keeps its name
module Main exposing (main)
import Html exposing (text)
safeDivide : Float -> Float -> Result String Float
safeDivide numerator denominator =
if denominator == 0 then
Err "cannot divide by zero"
else
Ok (numerator / denominator)
main =
case safeDivide 10 2 of
Ok quotient ->
text (String.fromFloat quotient)
Err reason ->
text reason
let safeDivide = (numerator, denominator) =>
if denominator == 0.0 {
Error("cannot divide by zero")
} else {
Ok(numerator /. denominator)
}
switch safeDivide(10.0, 2.0) {
| Ok(quotient) => Js.log(quotient)
| Error(reason) => Js.log(reason)
} The built-in
result<'ok, 'error> spells Elm's Err out as Error and otherwise matches one to one. As in Elm, the type forces the caller to acknowledge both branches before touching the value.Exceptions
Exceptions exist here
Elm deleted exceptions. ReScript, sitting on JavaScript, keeps them: you can declare,
raise, and catch — though the culture prefers option and result.module Main exposing (main)
import Html exposing (text)
-- Elm's only failure channel is a value.
parsed : Result String Int
parsed =
String.toInt "not a number"
|> Result.fromMaybe "parse failed"
main =
text (Debug.toString parsed)
exception DivisionByZero
let riskyDivide = (numerator, denominator) =>
if denominator == 0 {
raise(DivisionByZero)
} else {
numerator / denominator
}
let outcome = try riskyDivide(10, 0) catch {
| DivisionByZero => -1
}
Js.log(outcome) An
exception declaration is a variant constructor for the error channel, and try … catch pattern-matches on it like a switch. JavaScript libraries throw, so the boundary needs this; idiomatic ReScript converts to result there and stays Elm-shaped inside — the same discipline F# asks of you, with the same lack of enforcement.JavaScript Interop vs Ports
Calling JavaScript directly
This is the philosophical heart of the comparison. Elm quarantines JavaScript behind ports — asynchronous message passing, no direct calls. ReScript binds to any JS value with one
external line and calls it synchronously.port module Main exposing (main)
import Html exposing (text)
port toJavaScript : String -> Cmd msg
main =
text "JS interop crosses a port boundary" @val external now: unit => float = "Date.now"
Js.log(now() > 0.0) The
@val external declaration gives Date.now a ReScript type and costs nothing at runtime — the compiled output calls Date.now() directly. Elm's ports (shown as a declaration; they need a JavaScript host to run) make the same interop an asynchronous, serialized message exchange. Elm buys guaranteed purity; ReScript buys the entire npm ecosystem at arm's length of one type annotation. This single trade explains most of why teams pick one or the other.Embedding raw JavaScript
For the last resort, ReScript can inline JavaScript itself — typed at the boundary by your annotation.
module Main exposing (main)
import Html exposing (text)
-- Elm has no escape hatch: the safe path is the only path.
main =
text (Debug.toString (String.toInt "42"))
let parseInteger: string => int = %raw("text => parseInt(text, 10)")
Js.log(parseInteger("42")) The
%raw block is trust-me territory: the compiler believes your annotation and checks nothing inside the string. Elm simply has no equivalent — there is no syntax for unchecked code, which is precisely why Elm's "no runtime exceptions" claim can be absolute while ReScript's safety is very strong but only as sound as its boundary annotations.