Learning ReScript
A quick tour of ReScript syntax, with examples and links to the official docs for deeper study.
ReScript compiles to readable JavaScript and fits into the same runtime, npm packages, and tooling you already use. What it changes is how precisely you can model data and program behavior before the code ships.
A First Look
The point of ReScript is not just that types are nice. It gives you a way to model real program states so the compiler can enforce rules you would otherwise keep in your head.
In many codebases, a value like this ends up spread across string values, nullable fields, and defensive checks. It works until one branch is forgotten during a refactor. ReScript pushes that information into the type itself:
1type user =2 | Guest3 | SignedIn(string)4 | Banned(string)56let greeting = user =>7 switch user {8 | Guest => "Welcome, stranger"9 | SignedIn(name) => `Hello, ${name}`10 | Banned(reason) => `Access denied: ${reason}`11 }A few things matter here:
type userdeclares a *variant* — a closed set of cases. The compiler knows every valueusercan hold.switchmatches each case directly. Add a new case touserlater (say,Suspended), and the compiler points out everyswitchthat no longer covers the type.- The function reads like straightforward application code, but the guarantees are stronger: there is no ambiguity about what shape can arrive at runtime.
- Types are inferred, so
greetingdoes not need an annotation. You get compiler help without turning the example into a wall of type syntax. - Template strings use backticks and
${...}for interpolation, like in modern JavaScript.
This is the practical case for ReScript: instead of relying on conventions, comments, or discipline to keep state handling correct, you encode the valid cases once and let the compiler enforce them everywhere.
Exhaustiveness checking is on by default. You cannot ship a switch with a missing case, which removes a whole class of forgotten-state bugs before the code runs.
Let Bindings and Functions
Most values are declared with let. Functions take their arguments in parentheses.
1let count = 12let add = (a, b) => a + b34let total = add(count, 41)Bindings are immutable by default. That usually makes code easier to follow because values do not quietly change underneath you.
Records and Variants
Records are object-like data with known fields. Variants are a fixed set of cases.
1type user = {2 name: string,3 admin: bool,4}56type status =7 | Idle8 | Saving9 | Failed(string)1011let currentUser = {name: "Ada", admin: true}12let currentStatus = SavingVariants are especially useful anywhere a value can be in one of several known states. You model the allowed cases once, then the compiler checks every place that consumes them.
Pattern Matching with switch
switch is the main branching construct. It handles destructuring and case coverage in one place.
1let statusLabel = status =>2 switch status {3 | Idle => "Ready"4 | Saving => "Saving..."5 | Failed(message) => `Failed: ${message}`6 }If you add a new variant case later, the compiler points out every switch that needs updating. The same applies when you match on records, tuples, or nested data:
1type role =2 | Guest3 | Member4 | Admin56type page =7 | Home8 | Settings910let canView = (role, page) =>11 switch (role, page) {12 | (Guest, Home) => true13 | (Guest, Settings) => false14 | (Member, _) => true15 | (Admin, _) => true16 }switch here matches a tuple of two variants. That is useful whenever behavior depends on more than one piece of state at once.
Options Instead of null
Missing values use option<'a>, with two cases: Some(value) and None.
1let maybeName: option<string> = Some("Ada")2let missingName: option<string> = None34let displayName = name =>5 switch name {6 | Some(value) => value7 | None => "Anonymous"8 }This changes day-to-day code in a few practical ways:
- Missing values are explicit in the type, so you can see right away which values need handling.
- You cannot accidentally read through
undefinedat runtime. The compiler makes you handleNonefirst. - There is one absence model instead of juggling
null,undefined, and missing keys.
You will see this often in optional arguments, lookups, and decoded data.
Modules and Files
Each file becomes a module. A file named Counter.res exposes its values under Counter.
1/* Counter.res */2let initial = 03let increment = count => count + 145/* App.res */6let next = Counter.increment(Counter.initial)You get namespacing by default, which keeps larger codebases from turning into import and naming sprawl.
Why It's Worth It
Once the syntax clicks, the benefits are mostly about reducing ambiguity in the code:
- You model state directly instead of spreading it across booleans, strings, and nullable fields.
- Refactors are safer because the compiler shows you every affected branch.
optionremoves a large class ofnullandundefinedbugs.- Types are inferred, so the code stays compact.
- The output still fits naturally into existing JavaScript tooling.
The payoff gets bigger as the codebase grows. The more branches, edge cases, and moving parts you have, the more valuable those guarantees become.
Adding ReScript Incrementally
You do not need a rewrite to try it:
- A JS or TS app can import modules compiled from ReScript.
- ReScript targets the same runtime, bundler, and npm packages as the rest of your app.
- You can adopt it one module, feature, or library at a time.
That makes it easy to start with one bounded part of a codebase and expand only if it proves useful.
Keep Learning
The official ReScript site covers the full language and toolchain:
Once this page feels familiar, the next step inside Xote's docs is Signals, then View.