OCaml (5.4)
This assessment applies the coupling account — coupling as access to a shared resource, and the coupling floor [1], [2] — and the knowledge-embodiment theorem [3]; the language is assessed against its official reference [4].
1 Assessment
This page assesses OCaml in five parts: core coupling — what the language’s own constructs force; ecosystem coupling — what the surrounding platform, tooling, and community cost; engineering readiness — whether the artifact can be reproduced, verified, observed, worked in quickly, connected to other languages, and governed openly; the profile — the unscored facts a score cannot express; and the scoring detail — the mechanism marks and the arithmetic behind every score. A ✅ bullet marks a construct that helps. A ⚠️ or ❌ bullet marks a cost; the scoring model does not price every cost named here.
Core coupling 58/100 · Ecosystem coupling 84/100 · Engineering readiness 61/100
1.1 Core coupling
1.1.1 Type
- ✅ Variant types declare a checked case distinction:
type shape = Circle of float | Square of floatis a closed sum, and the compiler checks that amatchcovers every constructor - ✅ Types are static and inferred: The Hindley–Milner algorithm gives a principal type without annotations, and no value is untyped
- ✅ A signature hides a type behind an interface: A
.mlifile can exporttype tas abstract, so a consumer binds to the operations and not to the representation - ✅ A functor makes a type a parameter:
Set.Make(String)builds a set module for a given ordered type, so one algorithm is reused across types without run-time dispatch - ⚠️ Operators are rebound in scope rather than overloaded (T8):
+isintaddition and+.isfloataddition, and a module-qualified form such asInt64.(a + b)or a locallet (+) = ...changes which operator is in scope, so the reader must track the binding - ⚠️ The object layer is a second type system: Classes and row-polymorphic object types sit beside the core types, so a codebase can mix two paradigms
1.1.2 Construction
- ❌ Mutable state is the natural default container for shared data (C2): The idiomatic way to hold module state is a
refcell, anArray, or aHashtbl, each mutable, so any caller of the interface can change what the module holds - ✅ A variant constructor is the only way to build a value of the type:
Node (l, x, r)names the construction path, so every construction is visible in the type declaration - ✅ A record literal states every field:
{ x = 1.; y = 2. }must be complete unlesswithis used, so a field cannot be left unset silently - ✅ A signature can hide the constructor of an abstract type: With
type tabstract andval create : unit -> t, construction goes through the module, so the invariant holds by construction - ✅ A
letbinding evaluates its right-hand side once:let x = expensive ()evaluates at that point, and the result is a value rather than a re-evaluable expression - ⚠️ A
mutablefield relaxes the invariant: A record field declaredmutablecan be assigned at any time, so an invariant over the record is maintained by the code rather than by the type
1.1.3 Memory
- ✅ Collection is automatic with no manual free: The collector reclaims an unreachable block, and a program never calls
free - ⚠️ Collection is not tied to the last reference (M2): A block stays allocated until the collector decides, so a latency-sensitive path is coupled to a component it never calls
- ❌ There is no RAII (M3): There is no scope-bound destructor, so a file descriptor, a
Unix.file_descr, or another non-memory handle is released by an explicit call or a bracket such asFun.protect - ❌ There is no ownership model (M4): No static lifetime or aliasing rule ties a value to an owner, so a mutable structure that escapes its module is shared without a trace
- ✅
Fun.protectbrackets a resource:Fun.protect ~finally:close bodyruns the release on the normal path and on the exceptional path, so the release need not be repeated at every exit - ⚠️ The major heap is measured and tuned by hand:
Gc.setandGc.statcontrol and reportspace_overheadand the major cycle, so a latency-sensitive program reads collector statistics itself - ⚠️ C stubs and
Marshalsit outside the collector’s view: A value held in a C global must be registered withcaml_register_generational_global_root, andMarshal.from_stringbuilds a value the collector then owns, so the boundary is the programmer’s to manage
1.1.4 Error
- ❌ Exceptions are unchecked (Er2):
raise,Failure, andNot_foundare not declared in the type of a function, so a caller cannot see the failure set from a signature - ✅
resultis a composable value:('a, 'b) resultisOk of 'a | Error of 'b, andResult.map,Result.bind, andResult.foldchain operations without amatchat every step - ✅ A variant closes the failure set:
type error = Not_found | Permission_deniedmakes the cases finite, so amatchover the failure is checked for exhaustiveness - ✅
try ... withseparates the abnormal path: A handler can match an exception constructor such asSys_error _ | Unix.Unix_error _and re-raise cheaply withraise_notrace - ✅
Optioncarries an absence without an exception:Some xandNonegive a missing result a value, andOption.mapandOption.getcompose it - ⚠️ A wildcard handler hides a fault:
try f () with _ -> defaultcatches every exception, includingOut_of_memoryand an unintended bug, so the cause disappears
1.1.5 Concurrency
- ✅ OCaml 5 runs OCaml code on several cores:
Domain.spawnstarts a second domain, so the global runtime lock that serialised all code in earlier versions no longer applies - ✅ Effects express a scheduler in the language: An effect handler such as
Effect.Deep.try_withorEffect.Shallowdefines what an effect does, so a concurrency library is ordinary OCaml - ✅
Eiosupplies structured concurrency:Eio_main.rungives a scheduler with fibers, cancellation, and capability-scoped resources, so a program names a scope rather than a thread - ✅
Atomic,Mutex, andConditionare primitives of the runtime: A shared cell of type'a Atomic.thas defined operations, andMutex.lockguards a critical section - ✅ A channel passes values between domains:
Domainslib.ChanwithDomain.spawngives a message-passing design instead of a shared one - ⚠️ A library written for one domain must be revisited: Code that relied on the single runtime’s atomicity now shares state across domains, so the migration is a review rather than a recompile
1.1.6 Platform
- ✅ The native compiler emits machine code for many targets:
ocamloptproduces a native executable for x86-64, ARM64, RISC-V, and Power, andocamlcproduces bytecode wherever the virtual machine runs - ✅ A C stub reaches any system library: An
externaldeclaration plus a small C stub links a C library, so the language’s reach follows the reach of C - ✅ MirageOS links an OCaml program into a unikernel: A set of modules builds a freestanding image, so the target needs no operating-system process
- ✅ The JavaScript backends target the browser:
js_of_ocamlcompiles bytecode to JavaScript, andMelangecompiles OCaml to JavaScript modules - ⚠️ The foreign-function interface is manual: An
externaldeclaration and its C stub must be kept in step by hand, so the boundary carries the correctness burden of both sides
1.1.7 Trade-offs (not coupling)
- The object layer is opt-in: Classes and objects are separate from the module system, so a project can ignore them entirely
- Inheritance instead of a functor is a design decision: Using class inheritance where a functor would do is a choice the language permits and does not require
- Terse syntax is a stated preference:
let f x = x + 1and the;;toplevel separator are stylistic, and the adjustment period is not a forced dependency - Persistent structures trade space for sharing: A version-keeping data structure retains the older versions, which is a space cost rather than a coupling mechanism
- Interface files are a convention: A project can omit the
.mlifile, and the abstraction benefit follows from the discipline rather than from the language - Effect handlers are opt-in: A program can ignore effects and write plain code, so the paradigm adds no dependency unless it is used
1.2 Ecosystem coupling
1.2.1 Performance
- ✅
ocamloptcompiles ahead of time to native code: There is no interpreter and no warm-up, so a program starts at full speed - ✅ The core type system erases at compile time: An abstract type and a generic function carry no run-time dispatch, so the abstraction costs nothing to execute
- ✅ A functor is specialised at compile time:
Set.Make(String)produces a module whose calls are direct, so the reuse costs no dynamic dispatch - ✅ An unboxed representation is available per declaration: An all-float record and an
[@@unboxed]declaration avoid a heap indirection, so a numeric structure can be dense - ⚠️ A mixed record boxes its
floatfield:type t = { n : int; x : float }storesxin a box, and only an all-float record or an annotation removes it, so the representation is a per-declaration detail - ⚠️ Allocation rate drives the collection schedule: A high allocation rate fills the minor heap often, and the program cannot schedule that collection itself
1.2.2 Ecosystem
- ❌ The library ecosystem is small next to Haskell or Rust (Ec3): Fewer general-purpose packages exist, so some work starts from a blank module
- ✅
opamis a mature package manager:opamresolves versions, pins a package to a revision, and keeps per-project switches, and the opam repository index is curated - ✅
dunebuilds, tests, and documents a project: One file describes a library, an executable, and a test, so no external build system is needed - ✅
merlinandocaml-lspgive editor support: Type-directed completion, jump-to-definition, and error reporting are available in Emacs, VS Code, and others - ✅ Substantial systems are written in it: The Rocq (Coq) proof assistant, the Frama-C analyzer, Jane Street’s trading systems, and the Tezos blockchain are OCaml codebases
- ⚠️ A niche language means fewer answers: The community is smaller, so a team depends on the members it can find
1.2.3 Evolution
- ✅ OCaml 5 keeps the single-domain case working: A program that ran on 4.x recompiles on 5.x, and the multicore runtime is the same source language
- ✅ Version changes have been additive in practice: The core syntax and the standard library have stayed stable across decades
- ✅ A switch pins a compiler version per project:
opam switchkeeps an old compiler and a new one side by side, so a project upgrades on its own schedule - ✅ The multicore change has a migration path: Code that used
Threadscan move toDomainand a scheduler library such asEio, withDomainslibbridging the two - ⚠️ A single-domain library needs a review for OCaml 5: A global mutable structure that was safe under one runtime becomes shared state when several domains run
1.2.4 Domain
- ✅ Excellent fit for compilers and static analysis: Algebraic data types, pattern matching, and a module system match a syntax tree and a typed pass directly, and the OCaml compiler itself is written in OCaml
- ✅ Proven in proof assistants and verification: Rocq (Coq) and Frama-C are OCaml programs, and their term representations are OCaml variants
- ✅ Proven in finance: Jane Street’s trading systems are OCaml, and the language is used for typed, low-latency systems
- ✅ A strong fit for parsers and interpreters:
menhirgenerates a parser, and a variant-based abstract syntax tree keeps the grammar’s cases explicit - ⚠️ Numeric and tensor work needs a library: Dense array and tensor programming depends on a package such as
Owlor on a C binding, not on the core language - ⚠️ The object layer is rarely the right tool: Most OCaml code uses modules and variants, and the object system is a separate, optional design
1.2.5 Learning
- ❌ The abstraction layer is steep even when the syntax is not (L1): A newcomer learns inference, the module and functor system, and pattern matching together, although the language is generally considered easier to approach than Haskell
- ✅ Inference removes annotation work:
let rec fact n = if n = 0 then 1 else n * fact (n - 1)is accepted without a type written by hand - ✅ The error messages point at the mismatch: The compiler prints the inferred type and the conflicting type, and Merlin shows the type under the cursor
- ❌ The module language is a second language: Functors, signatures, and
module type ofare a topic of their own beyond the expression language - ⚠️ Reading an inferred signature is a skill:
val f : ('a -> 'b) -> 'a list -> 'b listmust be read fluently before an interface is easy to write
1.2.6 Syntax
- ✅
let,match, andtypeshare one shape: A binding, a case analysis, and a declaration all need no braces, and the toplevel ends a phrase with;; - ✅ Pattern matching is written as data:
match xs with [] -> 0 | x :: _ -> xstates the cases directly - ✅ The keyword set is small and consistent: The same
|-case form serves a variant declaration, amatch, and an exception handler - ✅ Terse for the type information it carries: One line declares a record, and inference fills in the annotations
- ⚠️ Integer and float operators differ by a character:
+is not+., and=is structural while==is physical, so a sign or a repetition changes the meaning - ⚠️ Extensions extend the notation:
let*binding operators, ppx rewrites, and[@@deriving]attributes are not core syntax, so a reader must know which extensions a file uses
1.2.7 Testing
- ✅ A test framework lives in the package repository:
alcotestandounit2provide assertions and runners, anddune runtestruns the suite - ✅ Inline and expect tests sit beside the code:
let%testandlet%expect_test(ppx_inline_test and ppx_expect) keep the expected output in the source and diff it on a run - ✅ The type checker catches faults before a test does: An exhaustiveness check reports a missing
matchcase at compile time, which removes a whole class of test - ✅
bisect_ppxmeasures coverage: An instrumented build reports which points were executed - ⚠️ A hand-written module is the usual stand-in: There is no dynamic mocking of a module, so a seam must be a functor parameter or a signature
- ⚠️ An effectful path needs its handler installed: A test of a scheduler runs the effect handler, which is a different setup from a test of plain code
1.2.8 Security
- ✅ Memory safety is the default: The collector and the type system prevent use-after-free, and an out-of-range
a.(i)raisesInvalid_argumentinstead of reading adjacent memory - ✅ A signature hides the representation: With
type tabstract in the.mli, a consumer can neither forge a value nor read its internals - ✅ A typed function cannot receive a value of another type: A value that reaches a typed parameter is of that type, so a string cannot become a program structure at that boundary
- ✅ The runtime bounds an array access: The bounds check is part of the language, so an off-by-one index is a defined exception
- ⚠️
Marshal.from_stringtrusts its input: A crafted byte string can build a value that the type system then trusts, so untrusted input must not be unmarshalled - ⚠️
Obj.magicand a C stub break the type system:Obj.magicreinterprets a value without a check, and a stub can return an ill-typed value
1.3 Engineering readiness
1.3.1 Reproducibility
- ❌ Builds are not byte-reproducible by default.
- ❌ Dependencies resolve without a lock by default.
1.3.2 Supply chain
- ⚠️ Published packages carry no signed provenance by default.
- ⚠️ The package registry is a flat name space.
1.3.3 Observability
- ⚠️ No stable tracing and metrics standard.
- ⚠️ The standard library has no structured logging.
1.3.4 Feedback
- ✅ No charged mechanism in this dimension.
1.3.5 Interop
- ✅ No charged mechanism in this dimension.
1.3.6 Governance
- ✅ No charged mechanism in this dimension.
1.4 Profile
Unscored, and kept separate from every score on purpose. The profile records what a mechanism price cannot express: what a running program costs, how well a coding model and an agent work in the language, and whether a safety-critical path exists.
1.4.1 Energy and cost
What this axis asks: what does a running program cost in energy and time?
1.4.2 AI and agent readiness
What this axis asks: how well does a coding model and an agent work in this language?
- ❌ Sparse training data: models write it unreliably [8].
1.4.3 Safety certification
What this axis asks: is there a certified or standards-qualified path to a safety-critical system?
- ⚠️ No certified or standards-qualified path for safety-critical systems.
1.5 Scoring detail
1.5.1 Core coupling Dimensions
| Dimension | Mechanism | Description | Present | Cost |
|---|---|---|---|---|
| Type | T1 |
dynamic no static check | — | 0 |
T2 |
weak static | — | 0 | |
T3 |
dynamic/duck dispatch | — | 0 | |
T4 |
no sum types | — | 0 | |
T5 |
no generics | — | 0 | |
T6 |
no namespaces | — | 0 | |
T7 |
type erasure/reflection | — | 0 | |
T8 |
operator overloading | ✅ | −4 | |
T9 |
inheritance required for polymorphism | — | 0 | |
T10 |
no null-safety | — | 0 | |
T11 |
no module boundary | — | 0 | |
T12 |
no effect tracking | ✅ | −8 | |
T13 |
async/sync colouring | — | 0 | |
T14 |
unchecked foreign boundary | ✅ | −4 | |
| Type subtotal | −15 (−16) | |||
| Construction | C1 |
global mutable state by default | — | 0 |
C2 |
no immutability by default | ✅ | −4 | |
C3 |
constructors coupled to allocation | — | 0 | |
C4 |
no constructor/validation | — | 0 | |
| Construction subtotal | −4 | |||
| Memory | M1 |
manual deallocation | — | 0 |
M2 |
GC non-deterministic pauses | ✅ | −8 | |
M3 |
no RAII | ✅ | −4 | |
M4 |
no ownership model | ✅ | −8 | |
| Memory subtotal | −15 (−20) | |||
| Error | Er1 |
no structured path | — | 0 |
Er2 |
unchecked exceptions only | ✅ | −8 | |
Er3 |
errors ignored/non-exhaustive | — | 0 | |
Er4 |
no composable result type | — | 0 | |
| Error subtotal | −8 | |||
| Concurrency | K1 |
GIL/no parallel | — | 0 |
K2 |
shared mutable state default | — | 0 | |
K3 |
manual sync no race detection | — | 0 | |
K4 |
no structured concurrency | — | 0 | |
K5 |
no primitives | — | 0 | |
| Concurrency subtotal | 0 | |||
| Platform | P1 |
cannot function outside one vendor/domain | — | 0 |
P2 |
single-vendor but other uses | — | 0 | |
P3 |
legacy/discontinued platform | — | 0 | |
| Platform subtotal | 0 |
Core Dimensions score: 58/100 — total cost = −42 (per-dimension subtotals are capped at −15; an uncapped cost is shown in parentheses where the cap bites).
1.5.2 Ecosystem coupling Dimensions
| Dimension | Mechanism | Description | Present | Cost |
|---|---|---|---|---|
| Performance | R1 |
interpreted no AOT/JIT | — | 0 |
R2 |
GC pauses | — | 0 | |
R3 |
JIT warmup | — | 0 | |
| Performance subtotal | 0 | |||
| Ecosystem | Ec1 |
no package manager | — | 0 |
Ec2 |
fragmented tooling | — | 0 | |
Ec3 |
small/niche/discontinued | ✅ | −8 | |
Ec4 |
proprietary | — | 0 | |
Ec5 |
platform-locked tooling | — | 0 | |
Ec6 |
monoculture | — | 0 | |
Ec7 |
legacy burden | — | 0 | |
| Ecosystem subtotal | −8 | |||
| Evolution | V1 |
breaking changes | — | 0 |
V2 |
frozen | — | 0 | |
V3 |
discontinued | — | 0 | |
V4 |
glacial standards | — | 0 | |
| Evolution subtotal | 0 | |||
| Domain | D1 |
domain/vendor lock-in | — | 0 |
D2 |
poor domain fit | — | 0 | |
D3 |
framework forces architecture | — | 0 | |
| Domain subtotal | 0 | |||
| Learning | L1 |
steep | ✅ | −8 |
L2 |
large surface/multi-paradigm | — | 0 | |
L3 |
sparse docs | — | 0 | |
| Learning subtotal | −8 | |||
| Syntax | S1 |
verbose | — | 0 |
S2 |
inconsistent | — | 0 | |
S3 |
punctuation-heavy | — | 0 | |
| Syntax subtotal | 0 | |||
| Testing | G1 |
weak tooling | — | 0 |
G2 |
poor debugging | — | 0 | |
G3 |
runtime-only errors | — | 0 | |
| Testing subtotal | 0 | |||
| Security | Q1 |
memory-unsafe | — | 0 |
Q2 |
dynamic/weak typing risk | — | 0 | |
Q3 |
known vulnerability/injection | — | 0 | |
| Security subtotal | 0 |
Meta Dimensions score: 84/100 — total cost = −16 (per-dimension subtotals are capped at −15; an uncapped cost is shown in parentheses where the cap bites).
1.5.3 Engineering readiness Dimensions
| Dimension | Mechanism | Description | Present | Cost |
|---|---|---|---|---|
| Reproducibility | Y1 |
build not reproducible | ✅ | −8 |
Y2 |
dependencies not pinned | ✅ | −8 | |
| Reproducibility subtotal | −15 (−16) | |||
| Supply chain | Y3 |
no signed provenance | ✅ | −8 |
Y4 |
registry without namespacing | ✅ | −4 | |
| Supply chain subtotal | −12 | |||
| Observability | Y5 |
no stable tracing standard | ✅ | −8 |
Y6 |
no structured logging | ✅ | −4 | |
| Observability subtotal | −12 | |||
| Feedback | Y7 |
slow build | — | 0 |
Y8 |
no fast in-editor analysis | — | 0 | |
| Feedback subtotal | 0 | |||
| Interop | Y9 |
no foreign-function interface | — | 0 |
Y10 |
no portable target | — | 0 | |
| Interop subtotal | 0 | |||
| Governance | Y11 |
single-vendor governance | — | 0 |
Y12 |
closed specification | — | 0 | |
| Governance subtotal | 0 |
Readiness Dimensions score: 61/100 — total cost = −39 (per-dimension subtotals are capped at −15; an uncapped cost is shown in parentheses where the cap bites).
Verdict: OCaml’s design yields low core coupling (72/100) because its abstraction is checked at compile time. A variant type closes a case set, an abstract type in a .mli hides its representation, a functor makes a type a module parameter, and the inference engine reports a mismatch before the program runs. The coupling that remains is mostly about state and lifetime: there is no ownership rule and no block-scoped release, exceptions are unchecked, and mutable containers are the natural way to hold module state. On the platform side, the small library ecosystem and the concepts of the module language are the real cost, while observation of the collector is a matter of tuning rather than of correctness.
The formal development. The definitions, theorems, and proofs behind this page are published in:
- On the Nature of Coupling: All Coupling as Shared Resource Access — Zenodo, 2026 — doi:10.5281/zenodo.21925122
- Coupling as a Consequence of Cohesion: Decomposition, the Coupling Floor, and Domain-Imposed Coupling — Zenodo, 2026 — doi:10.5281/zenodo.21925201
- On the Nature of Cohesion: Cohesion as Correctness Relative to a Partitioning Rule — Zenodo, 2026 — doi:10.5281/zenodo.21703193