PONY λ M2 Modula-2

JavaScript.CodeCompared.To/ReScript

An interactive executable cheatsheet comparing JavaScript and ReScript

JavaScript (ES2025) ReScript 12.3
Variables & Bindings
Let bindings (immutable by default)
const greeting = "Hello, JavaScript!"; const count = 42; const ratio = 3.14; console.log(greeting); console.log(count); console.log(ratio);
let greeting = "Hello, ReScript!" let count = 42 let ratio = 3.14 Js.log(greeting) Js.log(count) Js.log(ratio)
In ReScript, all let bindings are immutable by default — the opposite of JavaScript's let. There is no const keyword; immutability is the default, not an opt-in. To create mutable state, you must explicitly use ref().
Mutable state with ref()
let counter = 0; counter += 1; counter += 1; counter += 1; console.log(counter);
let counter = ref(0) counter := !counter + 1 counter := !counter + 1 counter := !counter + 1 Js.log(!counter)
Mutable state in ReScript requires wrapping a value in ref(). The !counter syntax dereferences the ref (reads the current value), and := assigns a new value. This explicit indirection makes every mutation visible at the call site.
Explicit type annotations
// JavaScript has no type annotations (use TypeScript for types) const name = "Alice"; const score = 95; const ratio = 0.92; const active = true; console.log(name); console.log(score);
let name: string = "Alice" let score: int = 95 let ratio: float = 0.92 let active: bool = true Js.log(name) Js.log(score)
Type annotations in ReScript are optional — the compiler infers them automatically — but writing them improves documentation and catches mistakes earlier. The annotation follows the binding name with a colon, similar to TypeScript syntax.
Discarding return values
const items = [1, 2, 3]; items.push(4); // return value (new length) is silently ignored console.log(items); console.log(items.length);
let items = [1, 2, 3] let _ = Js.Array2.push(items, 4) // push returns new length — must discard explicitly ignore(42) // explicitly ignore any value Js.log(items) Js.log(Js.Array2.length(items))
ReScript enforces that non-unit return values are either bound or explicitly discarded. Functions that return an important value (like push returning the new length) cannot be called silently — you must write let _ = ... or ignore(...). This prevents accidentally ignoring error codes.
Types & Numbers
Integers and floats are separate types
// JavaScript has one number type const sum = 10 + 3; // 13 const quotient = 10 / 3; // 3.333... (float division) const floatSum = 1.5 + 2.7; // 4.2 console.log(sum); console.log(quotient); console.log(floatSum);
// Int arithmetic uses +, -, *, / let sum = 10 + 3 // 13 let quotient = 10 / 3 // 3 (integer division — truncates) // Float arithmetic uses +., -., *., /. let floatSum = 1.5 +. 2.7 Js.log(sum) Js.log(quotient) Js.log(floatSum)
ReScript treats integers and floats as completely separate types with separate arithmetic operators. There is no automatic coercion — the compiler rejects 5 + 2.5 with a type error. Integer division truncates (10 / 3 is 3), just like most languages except JavaScript.
Converting between types
const age = 30; console.log("Age: " + String(age)); const price = 9.99; console.log("Price: $" + String(price)); const numStr = "42"; console.log(parseInt(numStr, 10) + 1);
let age = 30 Js.log("Age: " ++ string_of_int(age)) let price = 9.99 Js.log("Price: $" ++ Js.Float.toString(price)) let numStr = "42" Js.log(int_of_string(numStr) + 1)
ReScript never coerces types automatically — all conversions are explicit. Built-in functions like string_of_int, int_of_string, float_of_int, and Js.Float.toString handle the most common conversions. The compiler rejects implicit coercions.
Comparison operators
const x = 5; const y = 10; console.log(x === y); // false (strict equality) console.log(x !== y); // true console.log(x < y); // true console.log(x >= y); // false console.log("apple" < "banana"); // true (lexicographic)
let x = 5 let y = 10 Js.log(x == y) // false (structural equality) Js.log(x != y) // true Js.log(x < y) // true Js.log(x >= y) // false Js.log("apple" < "banana") // true (lexicographic)
ReScript uses == for structural equality (compares by value, not reference) and != for structural inequality. Unlike JavaScript's ==, there is no type coercion — 1 == "1" is a compile-time type error, not a runtime quirk.
Strings
String concatenation
const first = "Hello"; const second = "ReScript"; const greeting = first + ", " + second + "!"; console.log(greeting); const version = String(12); console.log("ReScript v" + version);
let first = "Hello" let second = "ReScript" let greeting = first ++ ", " ++ second ++ "!" Js.log(greeting) let version = string_of_int(12) Js.log("ReScript v" ++ version)
ReScript uses ++ for string concatenation. Because + is reserved for integer arithmetic, the compiler catches type errors at compile time — you cannot accidentally concatenate a string and a number with +.
String interpolation
const name = "Alice"; const age = 30; const greeting = `Hello, ${name}!`; const info = `Name: ${name}, Age: ${age}`; console.log(greeting); console.log(info);
let name = "Alice" let age = 30 // Expressions inside ${} must be strings — use conversion functions let greeting = `Hello, ${name}!` let info = `Name: ${name}, Age: ${string_of_int(age)}` Js.log(greeting) Js.log(info)
ReScript supports backtick template strings for interpolation. Unlike JavaScript, the expressions inside ${...} must already be of type string — there is no automatic coercion. Use string_of_int or Js.Float.toString to convert non-string values before interpolating.
Common string operations
const message = " Hello, World! "; const trimmed = message.trim(); const upper = trimmed.toUpperCase(); const lower = trimmed.toLowerCase(); console.log(trimmed); console.log(upper); console.log(lower);
let message = " Hello, World! " let trimmed = Js.String2.trim(message) let upper = Js.String2.toUpperCase(trimmed) let lower = Js.String2.toLowerCase(trimmed) Js.log(trimmed) Js.log(upper) Js.log(lower)
The Js.String2.* module exposes JavaScript's native string methods in a data-first style. The string is always the first argument, making them composable with the -> pipe operator: text->Js.String2.trim->Js.String2.toUpperCase.
Testing string contents
const email = "user@example.com"; console.log(email.includes("@")); console.log(email.endsWith(".com")); console.log(email.startsWith("user"));
let email = "user@example.com" let hasAt = Js.String2.includes(email, "@") let isDotCom = Js.String2.endsWith(email, ".com") let startsWithUser = Js.String2.startsWith(email, "user") Js.log(hasAt) Js.log(isDotCom) Js.log(startsWithUser)
Js.String2.includes, Js.String2.startsWith, and Js.String2.endsWith are direct wrappers over JavaScript's native string methods. They return bool, not a truthy value, and the compiler enforces that they are used in boolean contexts.
Splitting and joining strings
const csv = "Alice,Bob,Charlie,Diana"; const names = csv.split(","); console.log(names); console.log(names.length); const words = "the quick brown fox".split(" "); console.log(words.join("-"));
let csv = "Alice,Bob,Charlie,Diana" let names = Js.String2.split(csv, ",") Js.log(names) Js.log(Js.Array2.length(names)) let words = Js.String2.split("the quick brown fox", " ") Js.log(Js.Array2.joinWith(words, "-"))
Js.String2.split returns array<string>, not an untyped array. Js.Array2.joinWith only accepts array<string> — joining an array of integers requires converting each element to a string first.
Option Types
Some and None instead of null
const found = "treasure"; // a value const missing = null; // absence of value const describe = (opt) => opt !== null ? `Found: ${opt}` : "Not found"; console.log(describe(found)); console.log(describe(missing));
let found: option<string> = Some("treasure") let missing: option<string> = None let describe = (opt) => switch opt { | Some(value) => "Found: " ++ value | None => "Not found" } Js.log(describe(found)) Js.log(describe(missing))
ReScript's option<'a> type replaces both null and undefined. A value of type option<string> is either Some("text") or None. The compiler prevents accessing the inner value without unwrapping it first — null dereferences are a compile error, not a runtime surprise.
Safe computation with options
const safeDivide = (a, b) => b === 0 ? null : a / b; const showResult = (result) => result !== null ? `Result: ${result}` : "Error: division by zero"; console.log(showResult(safeDivide(10, 2))); console.log(showResult(safeDivide(10, 0)));
let safeDivide = (a, b) => if b == 0 { None } else { Some(a / b) } let showResult = (result) => switch result { | Some(value) => "Result: " ++ string_of_int(value) | None => "Error: division by zero" } Js.log(showResult(safeDivide(10, 2))) Js.log(showResult(safeDivide(10, 0)))
Returning option<'a> from a function that might fail is idiomatic ReScript. The caller must handle both Some and None cases in a switch — the compiler flags unhandled cases. This eliminates the JavaScript pattern of checking === null at every call site.
Interop with JavaScript null
// JavaScript APIs commonly return null or undefined const maybeValue = "hello"; // or null from a JS API const maybeEmpty = null; const display = (v) => v !== null && v !== undefined ? `Value: ${v}` : "No value"; console.log(display(maybeValue)); console.log(display(maybeEmpty));
let maybeValue = Js.Nullable.return("hello") let maybeEmpty: Js.Nullable.t<string> = Js.Nullable.null let display = (nullable) => switch Js.Nullable.toOption(nullable) { | Some(text) => "Value: " ++ text | None => "No value" } Js.log(display(maybeValue)) Js.log(display(maybeEmpty))
Js.Nullable.t<'a> represents a JavaScript value that may be null or undefined. Use it when calling JavaScript APIs that return null. Js.Nullable.toOption converts to a safe option for pattern matching, bringing JavaScript's nullable values into ReScript's type-safe world.
Arrays
Creating arrays
const numbers = [1, 2, 3, 4, 5]; const names = ["Alice", "Bob", "Charlie"]; const empty = []; console.log(numbers); console.log(names.length); console.log(empty.length);
let numbers = [1, 2, 3, 4, 5] let names = ["Alice", "Bob", "Charlie"] let empty: array<string> = [] Js.log(numbers) Js.log(Js.Array2.length(names)) Js.log(Js.Array2.length(empty))
ReScript arrays use the same [...] syntax as JavaScript but are typed — [1, 2, 3] has type array<int> and can only contain integers. Mixing types in an array is a compile error. Empty arrays need a type annotation so the compiler knows what type they will hold.
Array mutation
const items = [10, 20, 30]; items[0] = 99; // element assignment items.push(40); // appending console.log(items);
let items = [10, 20, 30] items[0] = 99 // element assignment let _ = Js.Array2.push(items, 40) // push returns new length — discard it Js.log(items)
Unlike let bindings (which are immutable by default), arrays in ReScript are always mutable — elements can be replaced with index assignment. The push function returns the new length, which must be explicitly discarded with let _ = ....
Transforming with map
const numbers = [1, 2, 3, 4, 5]; const doubled = numbers.map(n => n * 2); const squares = numbers.map(n => n * n); console.log(doubled); console.log(squares);
let numbers = [1, 2, 3, 4, 5] let doubled = Js.Array2.map(numbers, n => n * 2) let squares = Js.Array2.map(numbers, n => n * n) Js.log(doubled) Js.log(squares)
Js.Array2.map is the data-first equivalent of JavaScript's Array.prototype.map. The array is the first argument, the callback is the second. The callback type is fully inferred — the compiler knows that n is an int and that the return type must be consistent.
Filtering elements
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; const evens = numbers.filter(n => n % 2 === 0); const large = numbers.filter(n => n > 5); console.log(evens); console.log(large);
let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] let evens = Js.Array2.filter(numbers, n => mod(n, 2) == 0) let large = Js.Array2.filter(numbers, n => n > 5) Js.log(evens) Js.log(large)
Js.Array2.filter takes a predicate that returns bool — not a truthy/falsy value like JavaScript's filter callback. The compiler catches cases where the predicate accidentally returns a non-boolean. mod is ReScript's integer modulo operator (equivalent to JavaScript's %).
Reducing to a single value
const numbers = [1, 2, 3, 4, 5]; const sum = numbers.reduce((acc, n) => acc + n, 0); const product = numbers.reduce((acc, n) => acc * n, 1); console.log(sum); console.log(product);
let numbers = [1, 2, 3, 4, 5] let sum = Js.Array2.reduce(numbers, (acc, n) => acc + n, 0) let product = Js.Array2.reduce(numbers, (acc, n) => acc * n, 1) let maxVal = Js.Array2.reduce(numbers, (acc, n) => if n > acc { n } else { acc }, 0) Js.log(sum) Js.log(product) Js.log(maxVal)
Js.Array2.reduce takes arguments in data-first order: the array, then the callback, then the initial value. The callback signature (accumulator, element) => result matches JavaScript's reduce callback order. The accumulator and element types can differ — the compiler infers both.
Joining to a string
const words = ["Hello", "World", "from", "JavaScript"]; const sentence = words.join(" "); console.log(sentence); console.log(`Word count: ${words.length}`);
let words = ["Hello", "World", "from", "ReScript"] let sentence = Js.Array2.joinWith(words, " ") let count = Js.Array2.length(words) Js.log(sentence) Js.log("Word count: " ++ string_of_int(count))
Js.Array2.joinWith is the data-first version of JavaScript's Array.prototype.join. It only works on array<string> — joining an array of integers requires mapping each element through string_of_int first. Js.Array2.length returns the array length as an int.
Control Flow
if/else as an expression
const score = 85; // Ternary — if/else equivalent in expression context const grade = score >= 90 ? "A" : score >= 80 ? "B" : score >= 70 ? "C" : "F"; console.log(grade); console.log(score >= 70 ? "Pass" : "Fail");
let score = 85 let grade = if score >= 90 { "A" } else if score >= 80 { "B" } else if score >= 70 { "C" } else { "F" } Js.log(grade) Js.log(if score >= 70 { "Pass" } else { "Fail" })
In ReScript, if/else is an expression that returns a value — equivalent to JavaScript's ternary operator but more readable for multi-branch logic. Every branch must return the same type, and the compiler enforces this. There is no ternary ? : operator — if/else handles all cases.
Pattern-matching switch
const day = "Monday"; const kind = ["Saturday", "Sunday"].includes(day) ? "Weekend" : ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"].includes(day) ? "Weekday" : "Unknown"; console.log(kind);
let day = "Monday" let kind = switch day { | "Saturday" | "Sunday" => "Weekend" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" => "Weekday" | _ => "Unknown" } Js.log(kind)
switch in ReScript is a pattern-matching expression that returns a value. Multiple patterns can share a branch with |. The _ wildcard matches any value not covered by previous patterns. Unlike JavaScript's switch, there is no fallthrough — each branch is isolated.
When guards in switch
const temperature = 25; const description = temperature < 0 ? "Freezing" : temperature < 10 ? "Cold" : temperature < 20 ? "Cool" : temperature < 30 ? "Warm" : "Hot"; console.log(description);
let temperature = 25 let description = switch temperature { | temp when temp < 0 => "Freezing" | temp when temp < 10 => "Cold" | temp when temp < 20 => "Cool" | temp when temp < 30 => "Warm" | _ => "Hot" } Js.log(description)
when guards add conditional logic to switch patterns. The pattern binds the value to a name (temp), then the guard adds a boolean condition. Guards allow arbitrary expressions while keeping exhaustiveness checking intact — the compiler still errors if any case could be unhandled.
Pattern Matching
Matching on tuples
const [x, y] = [3, 7]; const location = x === 0 && y === 0 ? "Origin" : y === 0 ? `On x-axis at ${x}` : x === 0 ? `On y-axis at ${y}` : `At (${x}, ${y})`; console.log(location);
let point = (3, 7) let location = switch point { | (0, 0) => "Origin" | (x, 0) => "On x-axis at " ++ string_of_int(x) | (0, y) => "On y-axis at " ++ string_of_int(y) | (x, y) => "At (" ++ string_of_int(x) ++ ", " ++ string_of_int(y) ++ ")" } Js.log(location)
Tuples in ReScript are fixed-size, typed collections. Pattern matching destructures them into named variables within each branch. Patterns are checked in order — the compiler catches unreachable patterns and ensures all combinations are handled.
Nested destructuring
const alice = { name: "Alice", address: { city: "Paris", country: "France" } }; const { name, address: { city, country } } = alice; console.log("Name: " + name); console.log("City: " + city); console.log("Country: " + country);
type address = { city: string, country: string } type person = { name: string, address: address } let alice = { name: "Alice", address: { city: "Paris", country: "France" } } let { name, address: { city, country } } = alice Js.log("Name: " ++ name) Js.log("City: " ++ city) Js.log("Country: " ++ country)
ReScript supports nested destructuring in let bindings. The compiler verifies that every field accessed actually exists on the type and has the correct type — there is no runtime "cannot read property of undefined" error. The type definitions (type) make the structure explicit.
Exhaustiveness checking
// JavaScript has no exhaustiveness checking const describe = (status) => { switch (status) { case "active": return "Currently active"; case "inactive": return "Not active"; case "pending": return "Awaiting approval"; default: return "Unknown"; // must add default — no compiler help } }; console.log(describe("active")); console.log(describe("pending"));
type status = Active | Inactive | Pending let describe = (status) => switch status { | Active => "Currently active" | Inactive => "Not active" | Pending => "Awaiting approval" // Compiler error if any case is missing — no default needed } Js.log(describe(Active)) Js.log(describe(Pending))
Exhaustiveness checking is a compile-time guarantee: the compiler errors if any constructor of a variant type is missing from a switch. Adding a new constructor to a variant type immediately surfaces every switch that needs updating — without searching the codebase. This is impossible with JavaScript's string-based enums.
Variants
Simple enum-like variants
// JavaScript uses string constants as enums const NORTH = "North", SOUTH = "South", EAST = "East", WEST = "West"; const heading = NORTH; const describe = (dir) => `Heading ${dir.toLowerCase()}`; console.log(describe(heading)); console.log(describe(EAST));
type direction = North | South | East | West let heading = North let describe = (dir) => switch dir { | North => "Heading north" | South => "Heading south" | East => "Heading east" | West => "Heading west" } Js.log(describe(heading)) Js.log(describe(East))
Variants are ReScript's typed enum. Unlike JavaScript's string constants, variants are checked by the compiler — a typo like Norht is a compile error, not a silent bug that produces undefined. Pattern matching on variants is exhaustive, so missing any case is also a compile error.
Variants with payload
// JavaScript: discriminated union with type field const area = (shape) => { switch (shape.type) { case "circle": return Math.PI * shape.radius ** 2; case "rectangle": return shape.width * shape.height; default: return 0; } }; console.log(area({ type: "circle", radius: 5 }).toFixed(4)); console.log(area({ type: "rectangle", width: 4, height: 6 }));
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(Circle(5.0))) Js.log(area(Rectangle(4.0, 6.0)))
Variants can carry data (payloads). This is more type-safe than JavaScript's discriminated-union pattern with { type: "circle", radius: 5 } — the constructor name and its payload are a single typed unit. The compiler prevents mixing constructor types or passing the wrong payload.
Exceptions as variants
class ValidationError extends Error {} class NetworkError extends Error { constructor(msg) { super(msg); } } const validateAge = (age) => { if (age < 0) throw new ValidationError("Age cannot be negative"); if (age > 150) throw new NetworkError("Implausible age"); return age; }; try { console.log("Valid age: " + validateAge(25)); validateAge(-1); } catch (error) { if (error instanceof ValidationError) console.log("Validation failed"); else if (error instanceof NetworkError) console.log("Error: " + error.message); }
exception ValidationError exception NetworkError(string) let validateAge = (age) => if age < 0 { raise(ValidationError) } else if age > 150 { raise(NetworkError("Implausible age")) } else { age } try { Js.log("Valid age: " ++ string_of_int(validateAge(25))) let _ = validateAge(-1) } catch { | ValidationError => Js.log("Validation failed") | NetworkError(reason) => Js.log("Error: " ++ reason) }
ReScript exceptions are declared with exception and can carry typed payloads. The catch block uses pattern matching — each exception type is matched individually with |. Unlike JavaScript's untyped catch (error), the compiler knows which exceptions each function can raise.
Records
Record type definition
// JavaScript objects — no enforced type const alice = { name: "Alice", age: 30, email: "alice@example.com" }; console.log(alice.name); console.log(alice.age); console.log(alice.email);
type person = { name: string, age: int, email: string, } let alice = { name: "Alice", age: 30, email: "alice@example.com" } Js.log(alice.name) Js.log(alice.age) Js.log(alice.email)
Records in ReScript are typed objects defined with a type declaration. The compiler infers the record type from the fields used — you rarely need type annotations on record values. Accessing a non-existent field is a compile error, not an undefined runtime value.
Spread update syntax
const alice = { name: "Alice", age: 30 }; const olderAlice = { ...alice, age: 31 }; const renamed = { ...alice, name: "Alicia" }; console.log(alice.age); console.log(olderAlice.age); console.log(renamed.name);
type person = { name: string, age: int } let alice = { name: "Alice", age: 30 } let olderAlice = { ...alice, age: 31 } let renamed = { ...alice, name: "Alicia" } Js.log(alice.age) Js.log(olderAlice.age) Js.log(renamed.name)
Record spread update creates a new record with some fields replaced — the original is unchanged. The compiler checks that the updated fields exist on the type and have the correct types. Updating a field with the wrong type is a compile error.
Destructuring records
const point = { x: 3, y: 7 }; // Object destructuring in JavaScript const { x, y } = point; console.log(`x = ${x}`); console.log(`y = ${y}`); console.log(`sum = ${x + y}`);
type point = { x: int, y: int } let point = { x: 3, y: 7 } // Record destructuring in let binding let { x, y } = point Js.log("x = " ++ string_of_int(x)) Js.log("y = " ++ string_of_int(y)) Js.log("sum = " ++ string_of_int(x + y))
Records can be destructured in let bindings using the same field-pattern syntax as switch. The compiler verifies that every destructured field exists on the type and binds its type correctly. Attempting to destructure a non-existent field is a compile error.
Functions
Function definitions
const add = (x, y) => x + y; const multiply = (x, y) => x * y; const greet = (name) => `Hello, ${name}!`; console.log(add(3, 4)); console.log(multiply(5, 6)); console.log(greet("Alice"));
let add = (x, y) => x + y let multiply = (x, y) => x * y let greet = (name) => "Hello, " ++ name ++ "!" Js.log(add(3, 4)) Js.log(multiply(5, 6)) Js.log(greet("Alice"))
ReScript functions are defined with let bindings and arrow syntax, just like JavaScript arrow functions. Functions are values — they can be stored in bindings, passed as arguments, and returned from other functions. The compiler infers the full type signature from usage.
Labeled (named) arguments
// JavaScript: destructured object for named parameters const greet = ({ name, greeting }) => `${greeting}, ${name}!`; console.log(greet({ name: "Alice", greeting: "Hello" })); console.log(greet({ greeting: "Hi", name: "Bob" }));
let greet = (~name, ~greeting) => greeting ++ ", " ++ name ++ "!" Js.log(greet(~name="Alice", ~greeting="Hello")) // Labels can be passed in any order Js.log(greet(~greeting="Hi", ~name="Bob"))
Labeled arguments in ReScript are prefixed with ~ at both the definition and call site. Unlike JavaScript's destructured-object pattern, labeled arguments can be passed in any order and the compiler verifies that every required label is provided — with clear error messages when any are missing.
Optional arguments with defaults
// JavaScript: default parameter values const greet = ({ name, greeting = "Hello" }) => `${greeting}, ${name}!`; console.log(greet({ name: "Alice" })); console.log(greet({ name: "Bob", greeting: "Hi" }));
let greet = (~name, ~greeting="Hello", ()) => greeting ++ ", " ++ name ++ "!" Js.log(greet(~name="Alice", ())) Js.log(greet(~name="Bob", ~greeting="Hi", ()))
Optional labeled arguments have a default value and are followed by a unit argument () to signal that no more optional arguments follow. The trailing () is a ReScript convention that lets the compiler resolve optional arguments unambiguously.
Automatic currying
// JavaScript: explicit currying needed const add = (x) => (y) => x + y; const addTen = add(10); console.log(addTen(5)); console.log(addTen(20)); const doubled = [1, 2, 3].map(add(0)); // awkward console.log(doubled);
let add = (x, y) => x + y let addTen = add(10) // partial application — automatic currying Js.log(addTen(5)) Js.log(addTen(20)) let doubled = Js.Array2.map([1, 2, 3], add(1)) Js.log(doubled)
All ReScript functions are curried by default. Calling a function with fewer arguments than it expects returns a new function that accepts the remaining arguments — no explicit curry() wrapper is needed. This makes partial application natural, especially for composing array operations.
Recursive functions
const factorial = (n) => n <= 1 ? 1 : n * factorial(n - 1); const fibonacci = (n) => n <= 1 ? n : fibonacci(n - 1) + fibonacci(n - 2); console.log(factorial(5)); console.log(factorial(10)); console.log(fibonacci(8));
let rec factorial = (n) => if n <= 1 { 1 } else { n * factorial(n - 1) } let rec fibonacci = (n) => if n <= 1 { n } else { fibonacci(n - 1) + fibonacci(n - 2) } Js.log(factorial(5)) Js.log(factorial(10)) Js.log(fibonacci(8))
Recursive functions in ReScript must be declared with let rec. Without rec, the function name is not in scope inside its own body — the compiler prevents accidental self-reference in non-recursive functions. Mutual recursion uses let rec ... and ....
Higher-order functions
const double = (n) => n * 2; const square = (n) => n * n; const applyTwice = (f, x) => f(f(x)); console.log(applyTwice(double, 3)); // 12 console.log(applyTwice(square, 2)); // 16 const compose = (f, g) => (x) => f(g(x)); const doubleThenSquare = compose(square, double); console.log(doubleThenSquare(3)); // 36
let double = (n) => n * 2 let square = (n) => n * n let applyTwice = (f, x) => f(f(x)) Js.log(applyTwice(double, 3)) // 12 Js.log(applyTwice(square, 2)) // 16 let compose = (f, g) => (x) => f(g(x)) let doubleThenSquare = compose(square, double) Js.log(doubleThenSquare(3)) // 36
Functions in ReScript are first-class values — they can be passed, returned, and stored without any special syntax. The type system fully tracks function types: (int => int) => int => int is a valid inferred type. The compiler catches type mismatches in higher-order function arguments.
Pipe Operator
The -> pipe operator
// JavaScript: method chaining const result = [1, 2, 3, 4, 5] .map(n => n * 2) .filter(n => n > 4) .reduce((acc, n) => acc + n, 0); console.log(result);
// ReScript: data-first pipe operator let result = [1, 2, 3, 4, 5] ->Js.Array2.map(n => n * 2) ->Js.Array2.filter(n => n > 4) ->Js.Array2.reduce((acc, n) => acc + n, 0) Js.log(result)
The -> pipe operator passes its left-hand value as the first argument to the right-hand function. It enables data-transformation pipelines that read left-to-right, similar to JavaScript's method chaining but composable with any function — even those from different modules.
Chaining string transformations
const transform = (text) => text .trim() .toUpperCase() .split(" ") .join("-"); console.log(transform(" hello world "));
let transform = (text) => text ->Js.String2.trim ->Js.String2.toUpperCase ->Js.String2.split(" ") ->Js.Array2.joinWith("-") Js.log(transform(" hello world "))
Chaining multiple transformations with -> creates a readable pipeline. Unlike JavaScript's method chaining (which only works on objects with those methods), the pipe operator works with any function from any module. The type changes along the chain — string becomes array<string> after split.
JavaScript Dictionaries
Js.Dict for key-value data
const scores = {}; scores["Alice"] = 95; scores["Bob"] = 87; scores["Charlie"] = 92; const keys = Object.keys(scores); console.log(keys); console.log(keys.length);
let scores = Js.Dict.empty() Js.Dict.set(scores, "Alice", 95) Js.Dict.set(scores, "Bob", 87) Js.Dict.set(scores, "Charlie", 92) let keys = Js.Dict.keys(scores) Js.log(keys) Js.log(Js.Array2.length(keys))
Js.Dict is ReScript's wrapper over plain JavaScript objects used as dictionaries. All values must share the same type — Js.Dict.t<int> is a dict where every value is an integer. The compiler prevents accidentally mixing types in the same dict.
Safe lookup with option
const scores = {}; scores["Alice"] = 95; scores["Bob"] = 87; const lookup = (dict, name) => name in dict ? `${name}: ${dict[name]}` : `${name} not found`; console.log(lookup(scores, "Alice")); console.log(lookup(scores, "Unknown"));
let scores = Js.Dict.empty() Js.Dict.set(scores, "Alice", 95) Js.Dict.set(scores, "Bob", 87) let lookup = (dict, name) => switch Js.Dict.get(dict, name) { | Some(score) => name ++ ": " ++ string_of_int(score) | None => name ++ " not found" } Js.log(lookup(scores, "Alice")) Js.log(lookup(scores, "Unknown"))
Js.Dict.get returns option<'a> instead of undefined for missing keys. This forces the caller to handle the missing-key case explicitly through pattern matching, preventing the classic "Cannot read properties of undefined" runtime error.
JavaScript Interop
Logging with Js.log
console.log("String value"); console.log(42); console.log(true); console.log([1, 2, 3]); // Multiple values on one line console.log("The answer is:", 42);
Js.log("String value") Js.log(42) Js.log(true) Js.log([1, 2, 3]) // Multi-argument variants Js.log2("The answer is:", 42) Js.log3("Range:", 1, 10)
Js.log compiles directly to console.log with no overhead. The Js.log2, Js.log3, and Js.log4 variants accept multiple arguments and compile to a single console.log call. All variants accept any type — they are polymorphic.
Generic type parameters
// JavaScript: no enforced generics const intBox = { value: 42, label: "integer" }; const strBox = { value: "hello", label: "string" }; const getLabel = (box) => box.label; const getValue = (box) => box.value; console.log(intBox.value); console.log(strBox.value); console.log(getLabel(intBox));
type box<'a> = { value: 'a, label: string } let intBox: box<int> = { value: 42, label: "integer" } let strBox: box<string> = { value: "hello", label: "string" } let getLabel = (box) => box.label let getValue = (box) => box.value Js.log(intBox.value) Js.log(strBox.value) Js.log(getLabel(intBox))
Generic types in ReScript use type parameters written as 'a (a letter or word prefixed with an apostrophe). The compiler infers the concrete type at each usage site — box<int> and box<string> are distinct types, and the compiler prevents mixing them.
Binding to JavaScript globals
// JavaScript: globals are just available const pi = Math.PI; const root2 = Math.sqrt(2); const root9 = Math.sqrt(9); console.log(pi); console.log(root2); console.log(root9);
@val external mathPi: float = "Math.PI" @val external mathSqrt: float => float = "Math.sqrt" Js.log(mathPi) Js.log(mathSqrt(2.0)) Js.log(mathSqrt(9.0))
@val external declares a typed binding to a JavaScript global value or function. The string after = is the JavaScript identifier. The type annotation tells the compiler how to type-check calls — no runtime type checking occurs. This is how all of ReScript's Js.* modules are implemented internally.
Inline JavaScript with %raw
// Already in JavaScript — no interop needed const pi = Math.PI; const now = Date.now(); const absValue = Math.abs; console.log(pi); console.log(now > 0); console.log(absValue(-42));
let pi: float = %raw("Math.PI") let now: float = %raw("Date.now()") let absValue: int => int = %raw("Math.abs") Js.log(pi) Js.log(now > 0.0) Js.log(absValue(-42))
%raw(...) embeds raw JavaScript code directly into the compiled output. The compiler cannot type-check raw expressions — you must provide the type annotation. Use %raw sparingly, only when there is no typed ReScript API for the JavaScript you need.