Hello World & the Build
Hello, World
A TypeScript module is a sequence of statements, and there is no entry-point declaration to write.
let () = print_endline "Hello, World!"console.log("Hello, World!");OCaml's
let () = … binds the unit pattern, which runs the expression and asserts it produces nothing. TypeScript has no such notion: a module's top-level statements execute in order when it is loaded. console.log is not part of the language at all — it comes from the host, which is the browser here and Node when you run tsx, and that distinction between the language and its host matters more in JavaScript than in most places.String Interpolation
A template literal interpolates any expression, and nothing about it is checked against a type.
let name = "OCaml"
let year = 1996
let () = Printf.printf "%s appeared in %d\n" name yearconst name: string = "TypeScript";
const year: number = 2012;
console.log(`${name} appeared in ${year}`);OCaml's format string is a typed value: the compiler reads
%s and %d and demands exactly a string then an integer, so a mismatch is a compile error. A TypeScript template literal calls String() on whatever each expression evaluates to, so there is nothing to mismatch — swapping name and year would produce different text and no complaint. In exchange the syntax embeds arbitrary expressions inline, which OCaml needs sprintf and separate arguments for.dune and opam vs tsc and npm
Configuration rather than code, so neither column runs. One flag in the TypeScript column decides how much of a type system you actually get.
(* 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. *)// tsconfig.json
// {
// "compilerOptions": {
// "strict": true,
// "target": "ES2022",
// "module": "NodeNext"
// }
// }
//
// Build and run:
// npm install
// npx tsc
// node dist/main.js
//
// Dependencies come from npm, into ./node_modules."strict": true is not optional in practice, and it is worth knowing before reading anyone's code. Without it, null and undefined are members of every type, function parameters default to any, and most of what makes TypeScript worth using is switched off. It bundles several flags, of which strictNullChecks is by far the most important — see the nullability section. The other structural difference from OCaml is that node_modules is per-project by default, so there is no switch to activate and no global state to get wrong.The Type System Is Structural
Two Types With the Same Shape Are One Type
The defining difference, and one that cuts both ways — it removes a great deal of ceremony and removes a guarantee with it.
(* Records are NOMINAL: these two types have identical
fields and are still different types. Passing a
celsius where a fahrenheit is expected does not
compile. *)
type celsius = { degrees : float }
type fahrenheit = { degrees : float }
let describe_celsius (temperature : celsius) =
Printf.sprintf "%.1f degrees C" temperature.degrees
let () = print_endline (describe_celsius { degrees = 21.0 })// Types are STRUCTURAL: same shape means same type, with
// no declaration relating them at all.
type Celsius = { degrees: number };
type Fahrenheit = { degrees: number };
function describeCelsius(temperature: Celsius): string {
return `${temperature.degrees.toFixed(1)} degrees C`;
}
const reading: Fahrenheit = { degrees: 21.0 };
// Accepted, because the shapes match:
console.log(describeCelsius(reading));OCaml records and variants are nominal: a type is the one you declared, and two declarations with identical contents are unrelated. TypeScript is structural: a type is a description of a shape, and any value with that shape belongs. That means no adapter code to pass a value from one library to another, and it also means the
Fahrenheit above sails into a function that wanted Celsius. OCaml has structural typing too — in its object system and its polymorphic variants — but not for records and variants, which is where all the real code lives. The TypeScript workaround is a "branded" type, which fakes nominality by adding a field that exists only in the type.Extra Fields Are Usually Fine
Structural typing means "has at least this shape", so a value carrying extra fields still fits.
(* A record literal must have exactly the declared
fields — no more, no fewer. *)
type point = { x : int; y : int }
let describe (point : point) = Printf.sprintf "(%d, %d)" point.x point.y
let () =
let origin = { x = 0; y = 0 } in
print_endline (describe origin)type Point = { x: number; y: number };
function describe(point: Point): string {
return `(${point.x}, ${point.y})`;
}
// A variable with extra fields is accepted: it HAS the
// shape Point requires, plus more.
const labelled = { x: 0, y: 0, label: "origin" };
console.log(describe(labelled));This follows directly from structural typing and is usually what you want — a function asking for
{ x, y } should accept anything that has them. There is one exception worth knowing: an object literal written directly at the call site gets excess-property checking, so describe({ x: 0, y: 0, label: "origin" }) is rejected while the variable above is accepted. That inconsistency exists to catch typos in literals, and it surprises everybody once. OCaml has no such question: a record literal has exactly the fields of exactly one declared type, and a misspelled field is an error naming it.An Interface vs a Signature
An interface describes a shape a value must have; an OCaml signature describes what a module must provide, including an abstract type.
module type Describable = sig
type t
val describe : t -> string
end
module IntDescription : Describable with type t = int = struct
type t = int
let describe value = Printf.sprintf "the number %d" value
end
let () = print_endline (IntDescription.describe 42)interface Describable {
describe(): string;
}
class NumberBox implements Describable {
constructor(private value: number) {}
describe(): string {
return `the number ${this.value}`;
}
}
console.log(new NumberBox(42).describe());The
implements clause above is documentation, not the mechanism — because typing is structural, NumberBox would satisfy Describable even without it, and any object with a describe(): string method would too. Deleting the clause changes nothing but the error message when the class stops matching. The thing an OCaml signature does that an interface cannot is carry an abstract type: type t in the anchor column names a type the implementation chooses and callers cannot see through. TypeScript has no equivalent; the nearest approximations are generics and branded types.Erasure & Unsoundness
Types Vanish at Runtime
Both languages erase types before running. Only one of them proved anything first.
(* Types drive compilation and are erased too — but the
compiler PROVED the program before erasing them, so
the guarantee survives. *)
type shape = Circle of float | Square of float
let area = function
| Circle radius -> 3.14159 *. radius *. radius
| Square side -> side *. side
let () = Printf.printf "%.2f\n" (area (Circle 1.0))// Types are erased and the compiler let this through,
// because the assertion below tells it to trust us.
type Shape = { kind: "circle"; radius: number };
const value = { kind: "square", side: 2 } as unknown as Shape;
// Compiles. At runtime, value.radius is undefined.
console.log(value.radius);
console.log(typeof value.radius);OCaml erases types too — there is no runtime type information — but it does so after proving the program well-typed, so erasure costs nothing. TypeScript's type system is deliberately unsound:
as assertions, any, and untyped data crossing a boundary all let a value carry a type it does not have, and since nothing is checked at runtime the mismatch surfaces as undefined rather than an error. That is a design decision, not a bug — it is what lets TypeScript describe fifteen years of existing JavaScript. The habit an OCaml programmer needs is to treat every as as an unchecked assertion and to validate data at the boundary with a schema library rather than asserting it.any and unknown
TypeScript has two escape hatches and only one of them is safe. Knowing which is which is most of the discipline.
(* There is no escape hatch. Obj.magic exists and is
the equivalent of a loaded gun; ordinary code has no
reason to reach for it. *)
let parse_number text =
match int_of_string_opt text with
| Some number -> Printf.sprintf "parsed %d" number
| None -> "not a number"
let () = print_endline (parse_number "42")// any switches the checker off for that value entirely:
// this line COMPILES, and fails only when it runs.
const loose: any = "42";
try {
console.log(loose.toFixed(2));
} catch {
console.log("any let it through; it failed at runtime");
}
// unknown is the SAFE escape hatch. The same call on an
// unknown is a COMPILE error until it has been narrowed.
const safe: unknown = "42";
if (typeof safe === "string") {
console.log(`parsed ${Number.parseInt(safe, 10)}`);
}any disables checking for a value and everything reached through it — assignments, property access, calls — and it spreads, because anything derived from an any is also any. unknown is the type-safe counterpart: it accepts any value and permits nothing until you narrow it with a typeof check, an instanceof, or a type guard. OCaml has no equivalent of either; the closest thing is Obj.magic, which is deliberately hard to find and would be a code-review event. Prefer unknown and narrow, and treat every any in a codebase as a hole where the checker stops.Narrowing a Type
TypeScript's checker follows control flow, so an ordinary runtime check tells it what the type is in each branch.
(* The match IS the narrowing: each branch knows exactly
which constructor it has, and the compiler proves the
set is covered. *)
type value = Text of string | Number of int
let describe = function
| Text text -> Printf.sprintf "text of length %d" (String.length text)
| Number number -> Printf.sprintf "number %d" number
let () =
print_endline (describe (Text "hello"));
print_endline (describe (Number 42))// A typeof check narrows the type inside the branch. The
// compiler follows the control flow to work out what it is.
function describe(value: string | number): string {
if (typeof value === "string") {
return `text of length ${value.length}`; // value is string here
}
return `number ${value}`; // and number here
}
console.log(describe("hello"));
console.log(describe(42));This is control-flow-based narrowing, and it is one of TypeScript's genuinely clever pieces: a
typeof, an instanceof, a truthiness check, or a comparison against a literal all refine the type for the rest of the block. OCaml achieves the same effect through pattern matching, which is more uniform and comes with an exhaustiveness proof. TypeScript can prove exhaustiveness too, but only for a union and only with the never trick shown in the discriminated-unions section — it is not automatic the way match is.Validating Untyped Data
A type guard is how you turn an assertion into a check, and it is the single most valuable habit to carry over from OCaml.
(* Data entering an OCaml program is parsed into a
declared type, and the parse either succeeds or
reports why. There is no way to skip it. *)
type person = { name : string; age : int }
let parse_person name_field age_field =
match int_of_string_opt age_field with
| Some age when name_field <> "" -> Ok { name = name_field; age }
| Some _ -> Error "name must not be empty"
| None -> Error "age must be a number"
let () =
(match parse_person "Ada" "36" with
| Ok person -> Printf.printf "%s is %d\n" person.name person.age
| Error message -> print_endline message);
(match parse_person "Ada" "old" with
| Ok person -> Printf.printf "%s is %d\n" person.name person.age
| Error message -> print_endline message)type Person = { name: string; age: number };
// A user-defined type guard: the "value is Person" return
// type tells the compiler what a true result means.
function isPerson(value: unknown): value is Person {
return (
typeof value === "object" && value !== null &&
typeof (value as Person).name === "string" &&
typeof (value as Person).age === "number"
);
}
for (const candidate of [{ name: "Ada", age: 36 }, { name: "Ada", age: "old" }]) {
if (isPerson(candidate)) {
console.log(`${candidate.name} is ${candidate.age}`);
} else {
console.log("not a person");
}
}The return type
value is Person is a type predicate: when the function returns true, the compiler narrows the argument to Person for the rest of the block. That gives you the same shape as an OCaml parse — untyped data goes in, a known type comes out, and the failure case is visible — with one crucial difference: nothing verifies that the guard actually checks what it claims. A guard whose body is return true type-checks perfectly and lies. In practice most codebases delegate this to a schema library (zod, valibot, ArkType) that derives both the type and the validator from one declaration, which is much closer to what a ppx deriver does for OCaml.Variables & Types
const, let and Shadowing
Both languages have immutable bindings by default, and only one of them lets you rebind the same name in the same scope.
let () =
let count = 10 in
(* A second let SHADOWS. Both bindings exist; the first
is simply hidden. *)
let count = count * 2 in
Printf.printf "%d\n" countconst count = 10;
{
// A new BLOCK is needed: redeclaring in the same scope
// is an error, so shadowing means nesting.
const count2 = count * 2;
console.log(count2);
}
// Shadowing proper, in a nested scope:
const value = 5;
{
const value = 50;
console.log(value);
}
console.log(value);OCaml's
let … in introduces a new scope for each binding, so shadowing in sequence is natural and idiomatic. TypeScript's const is scoped to the enclosing block, and declaring the same name twice in one block is an error — so shadowing requires an actual nested block, as the second half shows. const means the binding cannot be reassigned, not that the value is immutable: const items = [1]; items.push(2) is perfectly legal. let allows reassignment; var is function-scoped, predates both, and should not be used.Type Inference
TypeScript infers a great deal and stops at the same boundary Rust does — a function's parameters.
(* Whole-program Hindley-Milner: nothing is annotated,
and the types are still fully known. *)
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" ])// Parameters must be annotated; everything else is
// inferred from them.
function add(first: number, second: number) {
return first + second; // return type inferred as number
}
function describe<T>(items: T[]) {
return items.length; // inferred as number
}
console.log(add(3, 4));
console.log(describe(["a", "b"]));OCaml runs Hindley–Milner over the whole program, so a function's type is discovered and writing it down is optional. TypeScript infers return types, local variables, array element types and generic instantiations, but not parameters — an unannotated parameter is an implicit
any, which strict mode makes an error. That is the same trade Rust makes and for the same reason: an error gets reported at the function that is wrong rather than wherever unification finally failed. TypeScript's inference is also, unlike OCaml's, not principal — there are places where it picks a reasonable answer rather than the most general one.Immutability Is Opt-In and Shallow
TypeScript can express immutability, and it is a compile-time claim about one level rather than a property of the value.
(* Immutable by default. A record field cannot be
assigned unless it was declared mutable. *)
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 shifted.xtype Point = { readonly x: number; readonly y: number };
const origin: Point = { x: 0, y: 0 };
// origin.x = 5 would be a compile error.
const shifted: Point = { ...origin, x: 5 };
console.log(origin.x, shifted.x);
// readonly is COMPILE-TIME only and shallow:
const nested = { inner: { value: 1 } } as const;
console.log(nested.inner.value);The spread
{ ...origin, x: 5 } is exactly OCaml's { origin with x = 5 }, and readonly makes assignment a compile error. Two caveats an OCaml programmer should hold onto. It is erased, so nothing prevents mutation from JavaScript that was never type-checked, and a readonly value passed to a function expecting a mutable one is accepted in some positions. And it is shallow: readonly on an object does not freeze what its fields point at. as const goes deeper for literals, and Object.freeze is the runtime version.Numbers & Strings
There Is Only One Number Type
JavaScript has one numeric type and it is a float, which changes what arithmetic means in ways an OCaml programmer will not expect.
(* int and float are different types with different
operators, and mixing them does not compile. *)
let () =
let count = 7 in
let average = float_of_int count /. 2.0 in
Printf.printf "%d\n" (count / 2);
Printf.printf "%.1f\n" average// number is an IEEE 754 double. There is no integer type,
// so integer division has to be asked for.
const count = 7;
const average = count / 2;
console.log(Math.trunc(count / 2));
console.log(average.toFixed(1));
// The consequence every JavaScript programmer meets:
console.log(0.1 + 0.2);
console.log(Number.MAX_SAFE_INTEGER);OCaml distinguishes
int from float at the type level and gives them separate operators (/ and /.), so integer division is the default for integers and mixing the two is a compile error. TypeScript's number is a double, so 7 / 2 is 3.5 and integer division needs Math.trunc or Math.floor. Integers above 2^53 lose precision — Number.MAX_SAFE_INTEGER is that boundary — and 0.1 + 0.2 prints the famous 0.30000000000000004. bigint exists for arbitrary-precision integers, written 42n, and does not mix with number.Strings
The same five-letter word gives two different lengths, and both answers are counts of something other than characters.
let () =
let text = "caffè" in
Printf.printf "length = %d\n" (String.length text);
print_endline (String.uppercase_ascii text);
print_endline (String.concat " | " (String.split_on_char 'f' text))const text = "caffè";
console.log(`length = ${text.length}`);
console.log(text.toUpperCase());
console.log(text.split("f").join(" | "));An OCaml
string is a sequence of bytes, so this word occupies six of them and String.length answers 6. A JavaScript string is a sequence of UTF-16 code units, so it answers 5 — closer to a human count for European text, and still wrong for anything outside the basic plane, where an emoji counts as 2. Note also that OCaml's uppercase_ascii says in its name that it only handles ASCII, which is honest; toUpperCase is Unicode-aware. String methods live on the value in TypeScript and in a module in OCaml, which makes them discoverable by typing a dot.Collections
Arrays, Not Lists
The default sequence type is mutable, and the habits that follow from that are the main thing to relearn.
(* An OCaml list is an IMMUTABLE linked list. Consing is
cheap, length walks it, and the original survives. *)
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)// A TypeScript array is a MUTABLE growable array. push is
// cheap, length is free, and there is no original left.
const numbers: number[] = [1, 2, 3];
numbers.unshift(0);
console.log(`the same array now has ${numbers.length}`);
// A non-destructive version has to be written as one:
const base = [1, 2, 3];
const extended = [0, ...base];
console.log(`extended ${extended.length}, base ${base.length}`);unshift, push, splice, sort and reverse all modify the array in place and are the methods people reach for first — sort in particular surprises everyone, since it returns the array it just mutated. The spread form [0, ...base] is the non-destructive equivalent of consing and is what to reach for when the original matters. Recent JavaScript added toSorted, toReversed and with, which are the non-mutating versions of the classic methods and are worth preferring.Map and the Object-as-Dictionary
TypeScript has a real
Map and also lets a plain object serve as a dictionary; the two are not interchangeable.let () =
let ages = Hashtbl.create 8 in
Hashtbl.replace ages "ada" 36;
Hashtbl.replace ages "alan" 41;
(match Hashtbl.find_opt ages "ada" with
| Some age -> Printf.printf "ada is %d\n" age
| None -> print_endline "unknown");
Printf.printf "entries = %d\n" (Hashtbl.length ages)const ages = new Map<string, number>([
["ada", 36],
["alan", 41],
]);
const age = ages.get("ada");
if (age !== undefined) {
console.log(`ada is ${age}`);
} else {
console.log("unknown");
}
console.log(`entries = ${ages.size}`);Map takes keys of any type, preserves insertion order, has a size, and does not inherit anything — which makes it the right default. A plain object used as a dictionary coerces every key to a string, inherits Object.prototype (so "toString" in object is true even for an empty one), and is what JSON gives you. Note the lookup shape: Map.get returns undefined for a missing key, which is TypeScript's option, and under strict the return type is number | undefined so the check above is enforced.map, filter and reduce
The same three operations, chained as methods rather than threaded with a pipeline operator.
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"const total = [1, 2, 3, 4, 5, 6]
.filter((number) => number % 2 === 0)
.map((number) => number * 2)
.reduce((sum, number) => sum + number, 0);
console.log(`total = ${total}`);OCaml's
|> works because the module functions are curried and take their data last. TypeScript's methods live on the array, so chaining needs no operator — and it is why JavaScript never adopted a pipeline operator despite years of proposals. Both versions build an intermediate array at each step; JavaScript's lazy equivalent is a generator or the newer iterator-helper methods. One thing to watch: reduce without an initial value throws on an empty array, exactly as OCaml's fold_left does not — the seed is mandatory there.Tuples
A TypeScript tuple is an array with a fixed length and per-position types — a type-level fiction over a runtime array.
let divide_and_remainder numerator denominator =
(numerator / denominator, numerator mod denominator)
let () =
let (quotient, remainder) = divide_and_remainder 17 5 in
Printf.printf "%d remainder %d\n" quotient remainderfunction divideAndRemainder(
numerator: number,
denominator: number,
): [number, number] {
return [Math.trunc(numerator / denominator), numerator % denominator];
}
const [quotient, remainder] = divideAndRemainder(17, 5);
console.log(`${quotient} remainder ${remainder}`);The destructuring reads almost identically. The difference is what the value is: an OCaml tuple is its own kind of thing, while
[number, number] is an ordinary JavaScript array that the type system promises has exactly two numbers. Since the promise is erased, tuple.push(3) compiles in some positions and the array then has three elements. TypeScript tuples can also have optional and rest elements ([string, ...number[]]) and named positions for documentation, neither of which OCaml has.Sets
Both have sets. OCaml's is persistent and needs a functor application; TypeScript's is mutable and built in.
module IntSet = Set.Make (Int)
let () =
let first = IntSet.of_list [ 1; 2; 3 ] in
let second = IntSet.of_list [ 3; 4 ] in
Printf.printf "union has %d\n" (IntSet.cardinal (IntSet.union first second));
Printf.printf "has 3? %b\n" (IntSet.mem 3 first)const first = new Set([1, 2, 3]);
const second = new Set([3, 4]);
console.log(`union has ${first.union(second).size}`);
console.log(`has 3? ${first.has(3)}`);OCaml's
Set.Make (Int) builds a module specialized to integer elements, and its union returns a new set leaving both operands untouched. JavaScript's Set is a builtin that preserves insertion order and compares members with === — which means two objects with identical contents are different members, since === on objects is identity. That makes Set useful for primitives and awkward for structured values, where OCaml's structural comparison just works. The set-algebra methods (union, intersection, difference) are a recent addition; older code does the same work with spreads and filters.Control Flow Without match
There Is No match
The single largest thing an OCaml programmer gives up, and the workarounds are all worse than the original.
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 ]// switch matches on VALUE equality only — no guards, no
// destructuring, and fallthrough unless you break.
function classify(value: number): string {
switch (value) {
case 0:
return "zero";
case 1:
case 2:
case 3:
return "small";
default:
return value < 0 ? "negative" : "ordinary";
}
}
for (const number of [0, -5, 2, 42]) {
console.log(`${number} is ${classify(number)}`);
}TypeScript has no pattern matching.
switch compares values with === and nothing more: no guards, no destructuring of the matched value, no binding of sub-parts, and no exhaustiveness unless you construct it manually. Or-patterns become stacked case labels relying on fallthrough, and a guard becomes a conditional in the default. Destructuring exists but only in bindings and parameters, not as a dispatch mechanism. A TC39 proposal for a real match expression has been in progress for years. Until it lands, the closest thing to OCaml's match is a discriminated union plus a switch on the tag, which the next section covers.Loops
The C-style
for is here, and so is the counter that needs no heap cell.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
donefor (let index = 1; index <= 3; index += 1) {
console.log(`for ${index}`);
}
let countdown = 3;
while (countdown > 0) {
console.log(`while ${countdown}`);
countdown -= 1;
}OCaml has no mutable local bindings, so a
while counter must live in a ref and be read with !. TypeScript's let is a genuine mutable binding. TypeScript also has for … of for iterating values and for … in for iterating an object's keys — the two are easy to confuse and for … in over an array gives you string indices, which is almost never wanted. Note also that OCaml has no break or continue, so a loop that needs to stop early is written as a recursive function or with an exception.Three Kinds of Equality
OCaml's structural
= has no TypeScript counterpart, and the gap is felt constantly.(* = is structural and works on any type, comparing
runtime representations. == is physical identity. *)
let () =
Printf.printf "%b\n" ([ 1; 2 ] = [ 1; 2 ]);
Printf.printf "%b\n" ([ 1; 2 ] == [ 1; 2 ]);
Printf.printf "%b\n" ("a" = "a")// === is identity for objects and value equality for
// primitives. There is no structural equality at all.
console.log([1, 2] === [1, 2]);
console.log("a" === "a");
// == coerces its operands and is best avoided entirely:
console.log(0 == "");
console.log(JSON.stringify([1, 2]) === JSON.stringify([1, 2]));OCaml's
= compares any two values structurally by walking their representations, so two equal lists are equal. JavaScript's === compares primitives by value and objects by identity, so two arrays with the same contents are never equal — which is why the JSON.stringify comparison above exists as a common hack, and why libraries ship a deepEqual. == additionally coerces its operands and produces results like 0 == "" being true; every style guide bans it. OCaml's == is physical identity, and it is the one people reach for by mistake in the other direction.Destructuring
Destructuring works on objects and arrays in both languages, and TypeScript adds renaming and defaults inside the pattern.
type person = { name : string; age : int }
let () =
let { name; age } = { name = "Ada"; age = 36 } in
Printf.printf "%s %d\n" name age;
match [ 1; 2; 3 ] with
| first :: rest -> Printf.printf "%d, then %d more\n" first (List.length rest)
| [] -> print_endline "empty" const { name, age } = { name: "Ada", age: 36 };
console.log(name, age);
const [first, ...rest] = [1, 2, 3];
console.log(`${first}, then ${rest.length} more`);
// Destructuring can rename and supply defaults:
const { name: who, nickname = "none" } = { name: "Alan" };
console.log(who, nickname);OCaml destructures a record in an ordinary binding, and destructures a list only through a
match — because let first :: rest = … is a partial pattern and draws an inexhaustive-match warning naming the empty case. That is the difference worth seeing: TypeScript's array destructuring never warns, and const [first] = [] silently binds undefined. What TypeScript adds is renaming (name: who) and per-field defaults inside the pattern, which OCaml has no syntax for. Destructuring is also how TypeScript gets named function arguments, as the optional-parameters row showed.Functions
Currying Is Possible, Not Default
Arrow functions make explicit currying tolerable, and it is still explicit.
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)// A two-parameter function needs two arguments. Currying
// means writing the nesting out.
const add = (first: number) => (second: number) => first + second;
const addTen = add(10);
console.log(addTen(5));
console.log(addTen(32));In OCaml
add 10 is ordinary application, because add genuinely has type int -> int -> int. TypeScript's arrow syntax makes the curried form short to write — (a) => (b) => … — and TypeScript infers its type correctly, but a normal function declaration is not curried and calling it with too few arguments is an error. The knock-on effect is that OCaml's point-free pipelines do not translate: TypeScript chains methods on the data instead, which is why the array methods read the way they do.Optional and Default Parameters
TypeScript has defaults but no named arguments, and the community answer is an options object destructured in the signature.
let greet ?(greeting = "Hello") ~name () =
Printf.printf "%s, %s!\n" greeting name
let () =
greet ~name:"Ada" ();
greet ~greeting:"Welcome" ~name:"Alan" ()// Defaults are positional. Named arguments come from
// destructuring an options object.
function greet({ name, greeting = "Hello" }: { name: string; greeting?: string }) {
console.log(`${greeting}, ${name}!`);
}
greet({ name: "Ada" });
greet({ greeting: "Welcome", name: "Alan" });OCaml labels an argument with
~ and defaults it with ?, and the trailing () exists because a function with optional arguments cannot otherwise be known to be fully applied. TypeScript has positional defaults (function greet(name: string, greeting = "Hello")) and marks a parameter optional with ?, but callers cannot name a positional argument. Destructuring an object parameter, as above, gets you named arguments with defaults and is completely standard — with the bonus that the options object is a value you can build up, which OCaml's form cannot be. Note the ? on greeting in the type: optional there means "may be absent", which is subtly different from "may be undefined".this Is Determined by the Call
A JavaScript method is a function stored on an object, and its receiver comes from how it is called rather than where it came from.
(* OCaml has no implicit receiver. A function that
operates on a record takes it as an argument, and
detaching it changes nothing. *)
type counter = { mutable total : int }
let bump counter = counter.total <- counter.total + 1
let () =
let counter = { total = 0 } in
let detached = bump in
detached counter;
detached counter;
Printf.printf "%d\n" counter.totalclass Counter {
total = 0;
bump() { this.total += 1; }
bumpArrow = () => { this.total += 1; };
}
const counter = new Counter();
// Detaching a METHOD loses its receiver.
const detached = counter.bump;
try {
detached();
} catch {
console.log("detached method lost its this");
}
// An arrow-function property captures it at construction.
const safe = counter.bumpArrow;
safe();
console.log(counter.total);OCaml has no implicit receiver at all:
bump counter takes the record as an ordinary argument, so passing the function around cannot break it. In JavaScript, counter.bump() passes counter as this because of the dot, and the same function called without one gets undefined. That is why detached methods break, why callbacks need .bind(this), and why the class-property arrow form exists — an arrow function captures this from where it was defined, so it survives detachment. This is a JavaScript inheritance, not a TypeScript decision, and it is one of the language's sharpest edges.Variadic Functions
TypeScript functions can take a variable number of arguments, with the type system tracking what they are.
(* OCaml functions have a fixed arity. A variable number
of arguments means passing a list. *)
let total numbers = List.fold_left ( + ) 0 numbers
let () =
Printf.printf "%d\n" (total [ 1; 2; 3 ]);
Printf.printf "%d\n" (total [ 1; 2; 3; 4; 5 ])function total(...numbers: number[]): number {
return numbers.reduce((sum, number) => sum + number, 0);
}
console.log(total(1, 2, 3));
console.log(total(1, 2, 3, 4, 5));
// And an existing array can be spread back into the call:
const values = [1, 2, 3, 4, 5];
console.log(total(...values));OCaml has no variadic functions — every function has a fixed arity, and a variable number of things is a list. That is arguably cleaner, and it means
Printf.printf's apparent variadicity is a piece of compiler magic around the format type rather than a general feature. TypeScript's ...numbers: number[] collects the remaining arguments into a typed array, and the spread total(...values) goes the other way. Rest elements can also appear in tuple types ([string, ...number[]]), so a function's exact argument shape can be described precisely.Objects, Interfaces & Classes
Records and Object Types
Object types and functional update line up almost exactly; only the spread syntax differs from OCaml's
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.ytype Point = { x: number; y: number };
const origin: Point = { x: 0, y: 0 };
const shifted: Point = { ...origin, x: 5 };
console.log(`(${origin.x}, ${origin.y})`);
console.log(`(${shifted.x}, ${shifted.y})`);The spread
{ ...origin, x: 5 } creates a new object with the listed field overridden, which is { origin with x = 5 } under different punctuation. Two differences underneath: TypeScript's object is mutable unless every field is readonly, and its type is structural, so any object with an x and a y is a Point. Note also that a spread is shallow — nested objects are shared between the original and the copy, exactly as OCaml's with shares its unchanged fields, which is invisible in OCaml because nothing can mutate them.Classes
Classes are ordinary in TypeScript and vestigial in OCaml, and TypeScript adds a shorthand OCaml has no version of.
(* OCaml has an object system and almost nobody uses it.
A record plus functions is the idiomatic spelling. *)
type account = { owner : string; mutable balance : int }
let deposit account amount = account.balance <- account.balance + amount
let () =
let account = { owner = "Ada"; balance = 0 } in
deposit account 50;
Printf.printf "%s has %d\n" account.owner account.balanceclass Account {
balance = 0;
constructor(public readonly owner: string) {}
deposit(amount: number): void {
this.balance += amount;
}
}
const account = new Account("Ada");
account.deposit(50);
console.log(`${account.owner} has ${account.balance}`);OCaml's objects are structurally typed and genuinely interesting, and real OCaml code uses records, variants and modules instead. TypeScript classes are JavaScript classes with type annotations, plus a few compile-time-only additions:
public, private and readonly on a constructor parameter declare and assign the field in one line, which is what public readonly owner above does. private is erased and enforced only by the compiler; the runtime version is a #field, which is genuinely inaccessible from outside.JSON
This is one of the largest practical differences between the two ecosystems, and it cuts in both directions.
(* OCaml's standard library has no JSON at all. Real code
uses yojson or ppx_yojson_conv from opam; this column
builds the text by hand to stay runnable. *)
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: number };
const person: Person = { name: "Ada", age: 36 };
const text = JSON.stringify(person);
console.log(text);
// Parsing returns any, so the type is a CLAIM, not a fact:
const parsed = JSON.parse(text) as Person;
console.log(parsed.name, parsed.age);JSON is JavaScript's own object literal syntax, so
JSON.stringify and JSON.parse are builtins and serialization is free. OCaml has no JSON in its standard library; yojson plus a ppx deriver gets you there with a dependency and a little ceremony — and gets you something TypeScript does not have, which is a checked conversion. JSON.parse returns any, so the as Person above is an unverified assertion: if the text is missing age, nothing complains and parsed.age is undefined. Validate at the boundary with a schema library rather than asserting; the next row shows the hand-written version.Discriminated Unions
Variants Become Tagged Unions
This is TypeScript's answer to the algebraic data type, and it is a good one — better than most languages outside the ML family manage.
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 =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "point" };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle": return 3.14159 * shape.radius * shape.radius;
case "rectangle": return shape.width * shape.height;
case "point": return 0;
}
}
const shapes: Shape[] = [
{ kind: "circle", radius: 1 },
{ kind: "rectangle", width: 2, height: 3 },
{ kind: "point" },
];
for (const shape of shapes) {
console.log(area(shape).toFixed(2));
}A discriminated union is a union of object types sharing a literal-typed tag field, and switching on that tag narrows each branch to exactly one member — so
shape.radius is available in the circle case and nowhere else. That is the same guarantee OCaml's match gives. Two real differences: the tag is a field you invent and maintain rather than a constructor the language provides, so nothing stops two unions using different tag names; and exhaustiveness is not automatic — this function compiles because every branch returns, and the next row shows how to demand more.Asking for Exhaustiveness
TypeScript can check exhaustiveness, and you have to ask for it in every switch, by hand.
(* Exhaustiveness is automatic and unconditional: remove
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 ]type Status = "pending" | "active" | "closed";
function describe(status: Status): string {
switch (status) {
case "pending": return "waiting";
case "active": return "running";
case "closed": return "finished";
default: {
// If a case is ever missed, status is not never
// here, and THIS LINE fails to compile.
const unreachable: never = status;
return unreachable;
}
}
}
for (const status of ["pending", "active", "closed"] as Status[]) {
console.log(describe(status));
}The trick is
never, the type with no values. When every case is handled, TypeScript narrows status in the default branch to never, and assigning it to a never variable is fine. Add a fourth member to Status and that assignment becomes an error naming the unhandled case — which is exactly OCaml's warning, obtained by construction rather than by the compiler's own initiative. It is idiomatic and widely used, and it is opt-in per switch: forget it once and adding a case silently changes behavior. This is the single biggest reason to be careful when translating a variant-heavy OCaml design.Union, Intersection & Literal Types
Unions Without a Declaration
A union type combines types that already exist, with no constructor wrapping the values — which is something OCaml genuinely cannot express.
(* The closest OCaml gets is a polymorphic variant, which
also needs no declaration — but the tags are OCaml's,
not arbitrary existing types. *)
let describe value =
match value with
| `Text text -> Printf.sprintf "text %s" text
| `Number number -> Printf.sprintf "number %d" number
let () =
print_endline (describe (`Text "hi"));
print_endline (describe (`Number 42))// A union of EXISTING types, with no wrapper and no tag.
function describe(value: string | number): string {
return typeof value === "string"
? `text ${value}`
: `number ${value}`;
}
console.log(describe("hi"));
console.log(describe(42));OCaml's polymorphic variants are the nearest thing: they need no declaration and a value can belong to several types. But they still wrap — a
`Text "hi" is not a string, and unwrapping is required. TypeScript's string | number contains the plain values themselves, so no construction or destruction happens at all and any existing value fits. This is the piece of TypeScript's type system that has no OCaml counterpart, and it is what lets the language describe JavaScript APIs that genuinely accept several unrelated types. The cost is that narrowing depends on a runtime test rather than a tag the language guarantees.Literal Types
A single literal value can be a type in TypeScript, and combining those into unions is how most enumerations are written.
(* A value cannot be a type. The closest equivalent is
a variant with constant constructors. *)
type direction = North | South | East | West
let opposite = function
| North -> South | South -> North
| East -> West | West -> East
let to_string = function
| North -> "north" | South -> "south"
| East -> "east" | West -> "west"
let () = print_endline (to_string (opposite North))// The string "north" is itself a type, inhabited by
// exactly that one value.
type Direction = "north" | "south" | "east" | "west";
const opposites: Record<Direction, Direction> = {
north: "south",
south: "north",
east: "west",
west: "east",
};
console.log(opposites["north"]);OCaml has no way to make a value into a type:
North is a constructor of the direction type, and getting its name as a string means writing a converter. TypeScript's "north" is a type whose only value is that string, so a union of them is an enumeration whose members are already strings — no conversion needed, and they serialize to JSON directly. Record<Direction, Direction> additionally requires a key for every member, so leaving one out is a compile error, which recovers some of the exhaustiveness OCaml gives for free. TypeScript does have an enum keyword; string-literal unions are generally preferred over it.Intersection Types
An intersection type requires a value to satisfy several shapes at once, which OCaml has no way to express for records.
(* No intersection type. Combining two record types
means declaring a third that repeats their fields, or
nesting one inside the other. *)
type named = { name : string }
type aged = { age : int }
type person = { name : string; age : int }
let describe (person : person) = Printf.sprintf "%s is %d" person.name person.age
let () = print_endline (describe { name = "Ada"; age = 36 })type Named = { name: string };
type Aged = { age: number };
// & combines them. Nothing is redeclared.
type Person = Named & Aged;
function describe(person: Person): string {
return `${person.name} is ${person.age}`;
}
console.log(describe({ name: "Ada", age: 36 }));Named & Aged is the type of values that have both a name and an age, formed without declaring anything new. OCaml's record types are nominal and closed, so combining two means writing a third that repeats both sets of fields, or nesting — and either way the resulting type is unrelated to the originals. Intersections make TypeScript's mixin patterns and extension objects possible. Their edge case is worth knowing: intersecting two object types that disagree on a field's type produces a field of type never, which is legal and uninhabitable rather than an error.Generics & Type-Level Programming
Generic Functions
Both work for any element type. OCaml infers the generalization; TypeScript needs the parameter declared and then infers its instantiation.
(* Inferred and generalized automatically, with no
annotation and no type parameter written down. *)
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")function firstOr<T>(items: T[], fallback: T): T {
return items.length > 0 ? items[0] : fallback;
}
console.log(firstOr([1, 2], 0));
console.log(firstOr<string>([], "empty"));OCaml infers
'a list -> 'a -> 'a with no annotation, because parametric polymorphism is the default rather than a feature. TypeScript needs <T> written out, and then infers T at each call from the arguments — so the explicit <string> above is only needed because the empty array gives it nothing to infer from. Both are resolved entirely at compile time and erased. TypeScript adds constraints (<T extends { length: number }>) and defaults (<T = string>), which have no OCaml analogue on a plain function.Types Computed From Types
TypeScript can compute a type from another type, and it is a whole sub-language OCaml has no version of.
(* There is no type-level computation. A "partial"
version of a record is a second record, written out
by hand. *)
type point = { x : int; y : int }
type partial_point = { x : int option; y : int option }
let describe (point : partial_point) =
Printf.sprintf "%s %s"
(match point.x with Some value -> string_of_int value | None -> "?")
(match point.y with Some value -> string_of_int value | None -> "?")
let () = print_endline (describe { x = Some 1; y = None })type Point = { x: number; y: number };
// Partial<T> DERIVES a new type from Point, making every
// field optional. Nothing was written twice.
type PartialPoint = Partial<Point>;
function describe(point: PartialPoint): string {
return `${point.x ?? "?"} ${point.y ?? "?"}`;
}
console.log(describe({ x: 1 }));Partial<T> is a mapped type: it iterates the keys of T and produces a new type with each field optional. The standard library ships Partial, Required, Readonly, Pick, Omit, Record and ReturnType, and you can write your own with keyof, indexed access, conditional types (T extends U ? X : Y) and template literal types. The result is a Turing-complete type-level language, which is powerful and also the reason some TypeScript codebases have types nobody can read. OCaml keeps the type language deliberately small; what it offers instead is the module system, where the abstraction happens at the value level.Types Derived From Values
TypeScript can look at a value and compute its type, then compute further types from that — with no runtime cost and nothing written twice.
(* A value's type is not itself a value, and there is no
way to compute a type from one. The list of field
names has to be written out separately. *)
type settings = { theme : string; font_size : int }
let field_names = [ "theme"; "font_size" ]
let () =
let settings = { theme = "dark"; font_size = 14 } in
Printf.printf "%s %d\n" settings.theme settings.font_size;
List.iter (Printf.printf "%s ") field_names;
print_newline ()const settings = { theme: "dark", fontSize: 14 };
// typeof lifts a VALUE into the type world; keyof takes
// the union of a type's keys. Neither is written twice.
type Settings = typeof settings;
type SettingName = keyof Settings; // "theme" | "fontSize"
function get<K extends SettingName>(name: K): Settings[K] {
return settings[name];
}
console.log(get("theme"), get("fontSize"));
console.log(Object.keys(settings).join(" "));typeof settings in a type position means "the type this value was inferred to have", and keyof turns a type into the union of its key names. Combined with an indexed access (Settings[K]), the get above returns string for "theme" and number for "fontSize" — a different type per argument, derived rather than declared. OCaml has no bridge between the value and type worlds at all: a field-name list is a separate value that can drift out of step with the record, as the anchor column shows. This is a real capability TypeScript has and OCaml does not.Modules & the Missing Functor
Modules Are Files
An OCaml module is a language construct you can define anywhere; an ES module is a file, and its exports are marked one at a time.
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)// A module is a FILE, and exports are per-declaration.
// This one is inline for the sake of a runnable example.
//
// // geometry.ts
// export const PI = 3.14159;
// export function circleArea(radius: number) { return PI * radius * radius; }
//
// // main.ts
// import { PI, circleArea } from "./geometry.js";
const PI = 3.14159;
function circleArea(radius: number): number {
return PI * radius * radius;
}
console.log(PI.toFixed(4));
console.log(circleArea(2).toFixed(2));OCaml modules nest, can be defined inline, can be constrained by a signature, passed to a functor and packed into a value. ES modules are files with an
export keyword on each public declaration and an import naming the ones you want. There is no nesting, no signature, and no functor — TypeScript's old namespace keyword offered nesting and is deprecated for new code. One import gotcha worth knowing: under NodeNext resolution the specifier keeps the .js extension even though the source is .ts, because that is what the emitted file will be.No Functors
The functor apparatus has no counterpart, and for the common case a generic function with a callback is shorter and does the same work.
module type Comparable = sig
type t
val compare : t -> t -> int
end
module MakeLargest (Element : Comparable) = struct
let largest items =
List.fold_left
(fun best item -> if Element.compare item best > 0 then item else best)
(List.hd items) (List.tl 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 ])// A generic function with a comparator passed as a value
// covers the case, with no module to build or name.
function largest<T>(items: T[], isGreater: (left: T, right: T) => boolean): T {
return items.reduce((best, item) => (isGreater(item, best) ? item : best));
}
console.log(largest([3, 9, 4], (left, right) => left > right));
console.log(largest(["fig", "apple"], (left, right) => left > right));An OCaml functor takes a module of types and operations and produces a module. TypeScript has no module-level parameterization at all — the substitutes are generic functions taking their operations as ordinary parameters, as above, and generic classes or interfaces. The common case comes out shorter. What is lost is what functors are actually for: bundling a type with its operations so that the pairing is checked, and instantiating that bundle once rather than threading the operations through every call. TypeScript has no way to say "this type comes with these operations" and have the compiler supply them.
null and undefined
There Are Two of Them
JavaScript has two ways to be absent, and a TypeScript codebase has to pick a convention because the language will not.
(* One absent value, and it is a constructor of a type
that differs from the present case. *)
let lookup key = if key = "known" then Some "value" else None
let () =
(match lookup "known" with
| Some found -> print_endline found
| None -> print_endline "absent");
(match lookup "other" with
| Some found -> print_endline found
| None -> print_endline "absent")// undefined means "never set"; null means "set to nothing".
// Both exist, and most code has to handle both.
function lookup(key: string): string | undefined {
return key === "known" ? "value" : undefined;
}
for (const key of ["known", "other"]) {
const found = lookup(key);
console.log(found ?? "absent");
}
console.log(typeof undefined, typeof null);undefined is what a missing property, an unset variable or a function with no return gives you; null is an explicit "no value" someone assigned. Both are in the type system separately, so string | undefined and string | null are different types, and code that handles one may not handle the other. The ?? operator supplies a fallback for either — unlike ||, which also fires on 0 and "" and is a common bug. The typeof null answer of "object" is a bug from 1995 that can never be fixed. Under strict, neither value is a member of other types, which is the setting that makes this section tractable at all.Reaching Through Absence
Optional chaining collapses a whole chain of absence checks into two characters, and it is genuinely more ergonomic than OCaml's equivalent.
(* Chaining through options means binding at each step,
or defining a binding operator. *)
let ( let* ) = Option.bind
type address = { city : string }
type person = { address : address option }
let city_of person =
let* address = person.address in
Some address.city
let () =
print_endline (Option.value (city_of { address = Some { city = "Paris" } }) ~default:"unknown");
print_endline (Option.value (city_of { address = None }) ~default:"unknown")type Address = { city: string };
type Person = { address?: Address };
// ?. short-circuits the whole chain to undefined, and ??
// supplies the fallback.
function cityOf(person: Person): string {
return person.address?.city ?? "unknown";
}
console.log(cityOf({ address: { city: "Paris" } }));
console.log(cityOf({}));a?.b?.c evaluates to undefined the moment any link is null or undefined, without evaluating the rest — so a five-deep chain needs one ?. per link and no nesting. OCaml needs Option.bind at each step, or a let* binding operator defined first, and each step costs a line. The syntax also covers calls (callback?.()) and indexing (items?.[0]). What TypeScript does not give you is the discipline: nothing forces the check, so a type that is not marked optional is simply trusted, and untyped data at the boundary can violate that.Error Handling
Exceptions Are Untyped
Both languages catch by shape, and only one of them knows what shape it caught.
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)class TooLarge extends Error {
constructor(public readonly value: number) {
super(`too large: ${value}`);
}
}
function check(value: number): number {
if (value > 100) throw new TooLarge(value);
return value;
}
console.log(check(50));
try {
console.log(check(500));
} catch (error) {
// error is "unknown" — anything at all can be thrown.
if (error instanceof TooLarge) {
console.log(`too large: ${error.value}`);
}
}OCaml catches by pattern, so
| Too_large value -> both selects the exception and binds its payload, and the compiler knows the type. In TypeScript the caught value is typed unknown under strict, because JavaScript permits throwing anything — a string, a number, an object with no relation to Error. So every handler that wants the payload has to narrow with instanceof first. Neither language records in a function's type which exceptions it may raise, but OCaml at least gives the handler a typed pattern to match against.result, Written Out
TypeScript has no
result type, and discriminated unions make writing one genuinely pleasant.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)// No Result in the standard library, but a discriminated
// union gives you the same thing in three lines.
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
function parsePositive(text: string): Result<number, string> {
const parsed = Number.parseInt(text, 10);
if (Number.isNaN(parsed)) return { ok: false, error: `"${text}" is not a number` };
if (parsed <= 0) return { ok: false, error: "must be positive" };
return { ok: true, value: parsed };
}
for (const candidate of ["42", "oops"]) {
const outcome = parsePositive(candidate);
console.log(outcome.ok ? outcome.value : outcome.error);
}The three-line
Result above narrows correctly: after outcome.ok is checked, outcome.value is available in one branch and outcome.error in the other, with no assertion. That is real ML-style error handling and several popular libraries ship a fuller version. What is missing is the ecosystem convention — the standard library and nearly every npm package throws, so a codebase using Result is converting at every boundary. There is also no ? operator, so chaining several fallible steps means nesting or a helper, exactly as it does in OCaml without let*.Async & the Event Loop
async Colors Every Caller
Both columns print the same three lines. The difference is entirely in what had to change to make suspension possible.
(* OCaml 5's 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// task must be async to await, and every caller must be
// async to await it. The color spreads all the way up.
async function task(): Promise<number> {
console.log("before");
await Promise.resolve();
console.log("after");
return 42;
}
const result = await task();
console.log(result);TypeScript's
async/await compiles to a state machine and colors functions: a function that awaits must be async, its return type becomes a Promise, and every caller that wants the value must await it and therefore be async too. Introducing one asynchronous call at the bottom of a call chain means changing every signature above it. OCaml 5's task is an ordinary unit -> int that suspends anyway, because the handler decides what suspension means rather than the callee — which is why OCaml 5 needs no async keyword and why libraries like Eio present blocking-looking APIs that do not block.Running Several Things at Once
Both fan work out and collect it. Only one of them uses more than one core, and it is not the one that looks concurrent.
(* Domains are OS threads with their own minor heap:
real parallelism, and expensive enough that you make
a few rather than thousands. *)
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 interleaves
// waiting; it does not run anything in parallel.
const square = async (value: number): Promise<number> => value * value;
const results = await Promise.all([1, 2, 3, 4].map(square));
console.log(results.join(" "));OCaml 5's
Domain is an operating-system thread with its own minor heap, so several genuinely run at once on separate cores. JavaScript is single-threaded: Promise.all starts several asynchronous operations and waits for all of them, which overlaps waiting — for the network, a timer, a file — but never overlaps computation. A CPU-bound loop blocks the entire event loop, including rendering. Real parallelism means Web Workers in a browser or worker_threads in Node, which communicate by message passing and share no memory by default.A Rejected Promise
An async function that throws produces a rejected promise, and
await turns the rejection back into a thrown exception you can catch normally.(* An exception raised inside a computation propagates
normally and is caught where you put the handler. *)
exception Not_found_error of string
let lookup key =
if key = "known" then "value" else raise (Not_found_error key)
let () =
print_endline (lookup "known");
(try print_endline (lookup "other") with
| Not_found_error key -> Printf.printf "missing: %s\n" key)class NotFound extends Error {
constructor(public readonly key: string) {
super(`missing: ${key}`);
}
}
async function lookup(key: string): Promise<string> {
if (key === "known") return "value";
throw new NotFound(key);
}
console.log(await lookup("known"));
try {
console.log(await lookup("other"));
} catch (error) {
if (error instanceof NotFound) console.log(`missing: ${error.key}`);
}This is the part of
async/await that works better than it has any right to: try/catch around an await catches a rejection exactly as it would a synchronous throw, so error handling reads the same in both worlds. The trap is what happens when you do not await — a rejected promise nobody handles becomes an unhandled rejection that may be reported asynchronously, long after the code that caused it, or crash the process in Node. OCaml has no equivalent hazard because there is no deferred computation to lose track of; an unhandled exception unwinds immediately.The OCaml Lineage
You Do Not Have To Leave
Before adopting TypeScript wholesale, it is worth knowing that OCaml already runs in the browser and has for fifteen years.
(* This same OCaml compiles to JavaScript with
js_of_ocaml or Melange, keeping the type system, the
module system and exhaustive matching. *)
type shape = Circle of float | Square of float
let area = function
| Circle radius -> 3.14159 *. radius *. radius
| Square side -> side *. side
let () = Printf.printf "%.2f\n" (area (Circle 1.0))// The same program, hand-written in TypeScript. Compare
// what the tag costs and what the exhaustiveness costs.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle": return 3.14159 * shape.radius * shape.radius;
case "square": return shape.side * shape.side;
}
}
console.log(area({ kind: "circle", radius: 1 }).toFixed(2));js_of_ocaml compiles OCaml bytecode to JavaScript and is mature and widely used. Melange compiles OCaml source to readable JavaScript modules with a focus on interoperating with npm packages. Both keep the whole language — the module system, functors, exhaustive matching, sound inference — and both let you call JavaScript and be called from it. The reason to write TypeScript instead is rarely the language: it is that your colleagues read it, that the type definitions for every npm package are written in it, and that hiring, tutorials and Stack Overflow all assume it. Those are real reasons. They are just not claims about the type system.
ReScript Was an OCaml Compiler
The history is worth knowing, because it explains why so much of ReScript will look familiar and why some of it will not.
(* OCaml syntax. ReScript began life as BuckleScript,
an alternative backend for THIS compiler, and its
semantics are still largely OCaml's. *)
type user = { name : string; age : int }
let describe user = Printf.sprintf "%s is %d" user.name user.age
let () = print_endline (describe { name = "Ada"; age = 36 })// TypeScript, for comparison. ReScript would spell this
// almost exactly as the OCaml column does, and emit
// JavaScript that looks like this.
type User = { name: string; age: number };
function describe(user: User): string {
return `${user.name} is ${user.age}`;
}
console.log(describe({ name: "Ada", age: 36 }));ReScript started as BuckleScript, a JavaScript backend for the OCaml compiler, and for years you wrote OCaml and got JavaScript. It has since forked its syntax — curly braces,
switch for match, #Tag for a polymorphic variant — and made application uncurried, so it is now its own language rather than an OCaml dialect. But it kept far more than its reputation suggests: functors, module signatures, polymorphic variants, labeled and optional arguments, exceptions and first-class modules all still work, which is more than F# or Haskell can say. It also kept the part that matters most here: sound type inference with no any, real variants with exhaustive matching, and no null in any type. If the reason for learning TypeScript is "I need to ship to the browser" rather than "my team writes TypeScript", ReScript and Melange are worth an afternoon before committing.