OCaml and F# standards
Criteria verified as of August 2026. Re-verify on the web before committing to anything (§8).
1. Scope and triggers
Applies to the two strict MLs in industrial use. OCaml: .ml/.mli/.mll/.mly, dune, dune-project, dune-workspace, *.opam files, .ocamlformat, opam switches, Merlin/ocaml-lsp. F#: .fs/.fsi/.fsx, dotnet fsi, .fsproj, .fantomasignore, paket.dependencies.
They share lineage, they share nothing else. OCaml and F# descend from ML and that is why they look alike on the surface —Hindley-Milner inference, ADTs, exhaustive pattern matching, immutability by default—, but they share no ecosystem, no runtime, no package manager, no tooling and no concurrency model. OCaml has its own native compiler, its own GC, opam/dune and a module system with functors that F# does not have. F# is a language on top of the CLR: its runtime, its GC, its package manager (NuGet) and its support calendar are .NET's, and its interop with C# is a design requirement, not an extra. No decision transfers from one to the other by analogy. That is why each section below explicitly states which of the two governs; the little that is common is marked as [Both].
Not applicable:
dotnet-standards— critical boundary, no ambiguity. These belong todotnet-standards: the .NET SDK andglobal.json, the LTS version and its support calendar,Directory.Build.props/Directory.Packages.props, NuGet and its security (NuGetAudit, lockfiles, CPM), publishing and packaging, the runtime and Native AOT, ASP.NET Core, hosting, configuration, service observability and containerisation. These belong to this skill: how F# is written — the language and its idioms, discriminated unions and domain modelling with types, computation expressions,Resultversus exceptions,Fantomas,Expecto/FsCheck, type providers,dotnet fsias scripting, and the boundary with C# (nulls,Option, exposed interfaces). Arbitration rule: if the problem is ASP.NET Core,dotnet-standardswins even if the code is F#; if the problem is the shape of the type or of the F# code, this one wins.haskell-fp-standards(the other typed functional language: the boundary is laziness versus strict evaluation —here there are no thunks and no space leaks fromfoldl, and there is observable evaluation order— and the class of type system: modules/functors and effects via handlers here, type classes + higher-kinded types + monadicIOthere).scala-standards(functional on the JVM: the boundary is the runtime and Java interop, plus its choice of Typelevel/ZIO/Pekko ecosystem).rust-standards(shares ML lineage and ADTs; the boundary is the memory model —ownership without GC versus GC— and the purpose: systems and bounded latency versus correctness of the logic).jvm-spring-standards(only if the problem comes in through JVM/Spring).iac-standards(Nix as an infrastructure/deployment manager is theirs; Nix as a reproducible toolchain for this OCaml project —devShell, pinning opam/dune— is ours).cicd-standards(the pipeline and its cache),kubernetes-standards(OCI image and deployment),api-design-standards(the HTTP/gRPC contract; here only its implementation with Dream/opium or Giraffe/Falco),appsec-standards(threat modelling and agnostic vulnerability classes; here only the concrete sinks of these languages),vulnerability-management-standards(CVE triage and SLA; here only the audit gate),secrets-management-standards,observability-standards(OTel pipeline and SLOs; here only the instrumentation),sql-standards(the SQL that Caqti/Petrol or Dapper/EF generate),python-standards/go-standards/typescript-standards(language choice when neither of these two is the answer).
2. Default decisions and toolchain
Verify the latest version on the web before pinning it in a real project (§8). What follows is the state verified as of Aug 2026.
OCaml
| Piece | Choice | Verified state (Aug 2026) | Note |
|---|---|---|---|
| Compiler | OCaml 5.5.0 | released 2026-06-19 | Previous: 5.4.1 (2026-02-17). Adds module-dependent functions and a relocatable compiler |
| 4.x line | 4.14.4 (2026-06-15) | maintenance; support announced at least until the end of 2026 | Legacy only. No new project on 4.x; plan the exit now |
| Manager | opam ≥ 2.5.2 | 2.5.2 released 2026-07-08 | Minimum version for security, not for features (§5) |
| Build | dune 3.24.0 | 2026-06-30 | directory targets move to general availability |
| Formatter | ocamlformat 0.29.0 | 2026-03-17 | .ocamlformat file with the version pinned (mandatory) |
| IDE | Merlin 5.7.1-504 + ocaml-lsp 1.26.0 | 2026-04-30 / 2026-04-10 | Merlin is paired to the compiler version (suffix -504) |
| Concurrency | Eio 1.4 (2026-07-23) in new projects on OCaml 5 | — | Lwt 6.1.2 (opam 2026-04-29, MIT) is still alive and is the safe option in existing code |
| Tests | Alcotest + QCheck | verify versions (§8) | Property-based is not optional |
| Base | Stdlib by default | — | Jane Street Base/Core is a whole-project decision (below) |
Non-negotiable minimums: opam + dune. Any OCaml project that does not use both is a project nobody from outside can build. No hand-crafted Makefiles invoking ocamlfind, no ocamlbuild. The opam switch is declared (dune-project + *.opam files generated by dune, or a versioned opam.locked) so that opam switch create . --deps-only reproduces the environment.
OCaml version policy. No formal LTS: roughly one minor a year is released and the previous ones stop receiving fixes quickly. Criteria: be on the current minor and apply the patch revision without delay — the recent precedent (OSEC-2026-01, §5) is that runtime security fixes ship as 5.x.1/4.14.z and falling behind means staying exposed. The minor jump is tested on a branch with the whole opam dependency matrix: the ecosystem takes weeks to update the ppxes, which are the first thing to break.
The real state of OCaml 5 and its multicore — there is a lot of confusion, this is what has been verified.
- Domains (shared-memory parallelism): the multicore runtime is OCaml 5's reason for existing and has been in production since 5.0. One domain ≈ one core; the useful number of domains is the number of available cores, not "thousands". For massive concurrency you use fibres/effects inside a domain, not one domain per task.
- Effect handlers: introduced in 5.0; syntactic support for deep handlers arrived in 5.3. And the critical point, quoted literally from the OCaml 5.5 manual: "Unlike languages such as Eff and Koka, effect handlers in OCaml do not provide effect safety; the compiler does not statically ensure that all the effects performed by the program are handled." That is: effects are NOT in the type system. They are declared by extending the extensible variant
Effect.t, and an effect without a handler is a runtime failure (Effect.Unhandled), not a compilation error. There is no calendar for a typed effect system. - Limitations you have to know before designing with effects: there are no multi-shot continuations (they are no use for backtracking); effects cannot be performed from a signal handler, a finaliser, a memprof callback or a GC alarm, and they do not cross the
caml_callbackboundary (FFI). - Standard library and parallelism:
Stdlibdoes not offer general concurrent structures; mutable state shared between domains is your responsibility (Mutex,Atomic, or structures from a library). Do not assume a library from the ecosystem is domain-safe: most were written assuming a single thread. Every dependency shared between domains is verified explicitly. - Operational conclusion: use effects for concurrency through a library that encapsulates them (Eio), not bare. Writing your own handlers is an architectural decision that requires an ADR and an owner.
Lwt versus Async versus Eio — criteria.
- New project on OCaml 5: Eio (1.4, Jul 2026). It is the native effects route: direct code with no promise monad, structured cancellation, io_uring integration. Cost: a smaller ecosystem and APIs that are still moving — read the changelog before bumping the version.
- Existing code or a hard dependency on the ecosystem: Lwt (6.1.2, MIT). Still alive and what a great many libraries assume. Migrating to Eio is a project, not a refactor.
Async: only inside a codebase that already lives in the Jane Street ecosystem (Core+Async+ its ppxes).Asyncis not chosen lightly: it drags in the whole stack.- FORBIDDEN to mix two of these in the same binary without an explicit and documented bridging layer. It is the number-one source of silent deadlocks in OCaml.
Stdlib versus Jane Street Base/Core — the real implication.
- It is not "one more library":
Baseis designed as a full replacement for the standard library. Afteropen Base,Stdlibmodules and values become deprecated and you have to reach them viaStdlib.String,Stdlib.print_string, etc.Basedoes not re-export what is not portable (I/O goes separately, instdio).Corestacks on top and adds batteries (time, containers, CLI, sexp). - What you gain: real consistency and safety — e.g.
Stdlib's structural polymorphic comparison, which compiles with any type and does what you do not expect, sits behindPolyinBase. And it opens the door to the rest of the suite (Async, ppx_let, sexplib, bin_prot). - What you pay: it is a whole-project, one-way decision. Divergent APIs you have to relearn, friction with the examples and libraries of the rest of the ecosystem, and a large dependency tree.
- Criteria: a single-team application, with people willing to learn the dialect ⇒
Coreis defensible. A library published on opam ⇒Stdlib(or at mostBase): you do not impose the base library choice on the consumer. Targets with portability constraints (js_of_ocaml, unikernels, embedded) ⇒Baserather thanCore. A single choice per repository, in an ADR.
F#
| Piece | Choice | Verified state (Aug 2026) | Note |
|---|---|---|---|
| Language | F# 10 | ships with .NET 10 (LTS) and Visual Studio 2026 | New features no longer need LangVersion=preview at GA |
| SDK / runtime / support | set by dotnet-standards |
— | The LTS calendar, global.json and EOL are not decided here |
FSharp.Core |
the one the SDK brings | — | Pinning a lower version by hand only with a written reason |
| Formatter | Fantomas (Apache-2.0) | see discrepancy below | .editorconfig for its options; .fantomasignore versioned |
| Tests | Expecto 11.1.0 (2026-06-17) | — | xUnit+FsUnit if the repo is already mixed .NET |
| Property-based | FsCheck 3.3.4 (2026-07-25) | — | The differential value (§4) |
| Packages | NuGet — governed by dotnet-standards |
— | Paket only in repos that already use it |
Declared discrepancy (Fantomas). The sources do not agree: NuGet showed as the latest version 7.0.5 (2025-12-05) while the GitHub repository has releases dated April 2026. No version number is pinned here: check NuGet and GitHub before pinning it (§8). What is verified and stable: Fantomas is under Apache-2.0 (raw LICENSE.md file from the repository).
F# and its coupling to .NET. F# has no independent version: F# 10 is "the F# that the .NET 10 SDK brings". You do not choose the F# version, you choose the SDK — and that choice, with its support dates, belongs to dotnet-standards. Consequences that do belong here: FSharp.Core is updated with the SDK unless explicitly pinned; the F# 10 novelties relevant when writing code are #nowarn/#warnon with bounded scope (suppressing a warning in a region instead of in the whole file — use it that way), attribute target validation (misplaced attributes used to be accepted silently: on bumping the SDK this shows up as new errors and that is correct), and ParallelCompilation as a project property (enabled by default for LangVersion=Preview in .NET 10, with a declared intention to generalise it in .NET 11 — verify before assuming it).
dotnet fsi and scripting. F# is the best scripting option in the .NET ecosystem and it should be used as such: .fsx with #r "nuget: Package, X.Y.Z" — always an explicit version, never a bare #r "nuget: Package", which resolves to the latest and is not reproducible. Scripts that survive their first month get promoted to a project with tests; a 500-line .fsx in production is debt. dotnet fsi is also the correct route for interactive exploration (a REPL with real types), far ahead of writing a throwaway console project.
3. Structure and conventions
OCaml
- Standard dune layout:
lib/(library, everything important),bin/(thin executable),test/.dune-projectat the root with(lang dune 3.x)pinned and(generate_opam_files true)so that the*.opamfiles are derived from thedune-projectinstead of diverging. .mlimandatory in every public module. It is not bureaucracy: it is the only point where you decide what is API and what is implementation, and what makes it possible for the.mlto change without breaking anyone. A module without an.mliexports everything it declares, including the details.- Abstract types by default:
type tin the.mliwithout its definition, with validating constructors. It is the OCaml version of "make impossible states unrepresentable", and it is stronger than in most languages because the compiler prevents building the value any other way. tconvention: a module's main type is calledt(User.t,Invoice.t); functions taketas the last argument so they compose with|>.- The module system and functors are what really sets OCaml apart, and they are the technical reason to choose it. Usage criteria, not a catalogue:
module type(signature) as a contract is the language's dependency inversion mechanism. It is used from the start: the domain layer depends on signatures, not on implementations.- Functor = a module parameterised by another module. It is justified when there are ≥2 real implementations of the same signature (a real and an in-memory storage backend, a comparison policy, a DB engine) or when you want to instantiate a data structure over a type with its operations (
Map.Make,Set.Make— the canonical use). - Functorising "just in case" is vetoed: a functor with a single instance is pure indirection, it worsens error messages and complicates the
dune. Start concrete, functorise when the second implementation shows up. includefor module composition; a globalopenof a large module at the head of a file is discouraged (it breaks tracking of where each name comes from) — boundedlet open M inorM.(...).
.ocamlformatversioned and with the tool version pinned inside (version = 0.29.0): without that line, two machines with different ocamlformat produce enormous diffs and the format gate becomes useless. Changing the version is a commit of its own that reformats the whole tree.- Merlin/ocaml-lsp on every workstation, with the version paired to the switch's compiler.
ppxin moderation: each one is code that runs at compile time, ties the project to the compiler version and is usually the first thing to break when bumping OCaml. Acceptable ones are the ecosystem core (ppx_deriving,ppx_yojson_conv,ppx_expect); a homegrown ppx requires an ADR and an owner.
F#
- File order in the
.fsprojis semantic: F# only sees what is declared before. That forces an acyclic dependency graph and is a design advantage, not a nuisance — take advantage of it by orderingDomain → Application → Infrastructure → Api. - Model the domain with types, not with validations: records for data, discriminated unions for alternatives, single-case types (
type Email = private Email of string) with a companion module exposingcreate : string -> Result<Email, Error>. The operational rule: if a state must not exist, make it impossible to write; loosebools andstrings with meaning are the smell to avoid. Result<'T,'TError>for expected domain failures, exceptions for the exceptional. An error type as a discriminated union per operation, notstring.Optionfor legitimate absence, never to signal a failure whose reason matters.- Computation expressions: use the ones that exist before writing one.
task { }for asynchrony (preferred overasync { }in new code for direct interop withTaskand lower cost);result { }/option { }(FsToolkit or equivalent) to chain without a pyramid ofmatch;seq { }for lazy generation. Writing your own CE requires an ADR: it is an API with its own rules that the whole team has to learn, and debugging inside someone else's CE is expensive. Never a CE for what|>andResult.bindsolve. - Type providers: powerful and dangerous. Acceptable for exploration and scripts (
.fsxover CSV/JSON/SQL). In compiled production code they require an ADR: they couple the build to a live external resource (a DB schema, a sample file, a service), they break reproducible and offline builds, and some have been left unmaintained. Default alternative: generate the types in an explicit step and version them. - The boundary with C# — where the danger lives. Everything arriving from C#/BCL is potentially
nulleven if the type says otherwise; F#'sOptiondoes not protect you from that.- Every input from C# is normalised at the edge:
Option.ofObj,isNull, null checks in the constructor, and from there on the inside of the F# module is null-free by construction. - Every output towards C# is designed as a C# API:
Option<'T>is awkward from C# (it shows up asFSharpOption<T>) — exposeTryGetwithout, ornull, or a type of your own; F#'s discriminated unions and tuples are not consumed well from C#; F# modules appear as static classes. If an F# library is consumed by C#, its public surface is designed for C# and tested from C#, it is not assumed. - Reference nullability: there is modern support for annotating nullable types in F#; verify the exact state and syntax in your SDK version before using it (§8) — do not apply it from memory.
- Every input from C# is normalised at the edge:
- Immutability by default:
mutable/ref/mutable arrays are decisions with a reason, normally measured performance and bounded to one function.
[Both] Exhaustive pattern matching as a compiler gate
It is the most valuable guarantee these two languages give and it is turned into a compilation error, always:
- OCaml:
(flags (:standard -w +a-4-9-40-41-42-44-45 -warn-error +8))— at a minimum, warning 8 (pattern-matching is not exhaustive) and 9 (record fields not mentioned, if the style allows it) treated as errors. Practical rule: the exact set of warnings is agreed once indune-project/duneand is not relaxed per file; a local[@warning "-8"]requires a reason comment. - F#:
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>and, at a minimum, FS0025 (incomplete pattern matches) and FS0049/FS0064 watched. With F# 10, suppressing a specific one is done with#nowarnbounded by region and#warnonto re-enable it — not at project level. - FORBIDDEN: the
_wildcard as a default branch over a domain discriminated union / variant. It is exactly what disables the guarantee: when a new case is added, the compiler must break every site that has to be reviewed._only over open or infinite types (integers, strings) or over types you do not control. - Design corollary: model with closed unions/variants instead of integers, strings or boolean flags. Exhaustiveness only protects you if the type is closed.
4. Quality and testing
[Both] Property-based testing is the differential value
In languages with ADTs and pure functions, generating cases is cheap and finds what nobody writes by hand. It is not optional in code with invariants.
- OCaml: QCheck (integrable into Alcotest via
qcheck-alcotest). F#: FsCheck 3.3.4 (integrable with Expecto and with xUnit). - Mandatory properties where they apply: serialisation round-trip (
decode (encode x) = xfor JSON, binary, persistence), smart constructor invariants (nothing built through the public API violates the invariant), laws of your own operations (associativity, idempotence, commutativity where they are claimed), and equivalence between an optimised implementation and an obviously correct one. - State model (state-machine testing) for stateful or concurrent logic: the only practical way to find races and invalid sequences.
- Generators are written to produce data that respects the domain's preconditions, not random
strings that only exercise input validation. A weak generator gives you a property that always passes and proves nothing. - The minimal case a failure reduces to (shrinking) is frozen as a regression unit test: the property detects, the unit test documents.
OCaml — tooling and gates
- ocamlformat with a versioned
.ocamlformat(version pinned inside). Gate:dune build @fmt/dune fmt --previewfailing if there are differences. - Tests: Alcotest (readable output, good dune integration) or
ppx_expectfor expectation/golden tests — very convenient for large, stable outputs, and updatable withdune promote; the risk is promoting without reading the diff: review every promotion in the PR. - QCheck for properties;
qcheck-alcotestto see them in the same suite. - Coverage with
bisect_ppxas a signal, never as a goal. - Documentation with odoc compiling in CI (
dune build @doc): a well-documented.mliis the contract. - CI gates, in order of cost:
dune build @fmt --diff-command=diff # formatting
dune build @all --profile release # warnings as errors (see §3)
dune runtest # unit + properties + expect tests
dune build @doc # odoc compiles
opam lint *.opam # package metadata
# dependency audit: see §5 and §8 (there is no `opam audit` as of Aug 2026)
F# — tooling and gates
- Fantomas as the formatting authority (Apache-2.0), configured through
.editorconfig, with a versioned.fantomasignore. Gate:dotnet fantomas --check .. - Expecto 11.1.0 by default in F#-first projects (tests as values, composable, a good story with FsCheck and with parallel tests). xUnit + FsUnit if the repository is already mixed .NET and you want a single test infrastructure — coherence over preference.
- FsCheck integrated in the same suite (
Expecto.FsCheck). - Warnings as errors (§3) and SDK analysis: the common
.csproj/.fsprojconfiguration,Directory.Build.propsand the analysers belong todotnet-standards; what this skill fixes is that FS0025 is never ignored. - Minimum gate:
dotnet fantomas --check .→dotnet build -warnaserror→dotnet test. The full pipeline (NuGet SCA, publish, container) is defined bydotnet-standards.
[Both] Test strategy
- Fast, deterministic unit tests over pure logic, which is most of the code if the design is right: pure domain, effects at the edge.
- Edges and errors mandatory: empty inputs, numeric limits, invalid decoding, timeouts, cancellation, unicode.
- Integration with the real dependency (same engine version as production) rather than driver mocks; mock your own boundaries (a module signature in OCaml, an interface or injected function in F#), not the world.
- Every bugfix leaves a regression test that fails before the fix. A flaky test: it is fixed or deleted.
5. Stack security
OCaml
Marshalover untrusted input — FORBIDDEN. It is code execution, not deserialisation.Marshal.from_string/from_bytes/from_channelandinput_valuereconstruct runtime values without validating types: a manipulated datum corrupts the heap and from there you get to arbitrary execution. Verified precedent: OSEC-2026-01 / CVE-2026-28364, a buffer over-read inruntime/intern.c(missing bounds validation inreadblock(),memcpy()with attacker-controlled lengths), fixed in OCaml 5.4.1 and 4.14.3 (2026-02-17). And the nuance that settles the discussion: the advisory itself records thatMarshal.from*andinput_valueremain unsafe to use — the fix hardens the runtime, it does not make the API safe. For data crossing a trust boundary: an explicit, parsed format (JSON withyojson/ppx_yojson_conv,bin_protwith known types and validation, protobuf), with size and depth limits.Obj.magicand theObjmodule — FORBIDDEN. It annuls the entire type system; the result is not a type error, it is memory corruption and a failure a thousand lines from the origin. No exceptions in application code. In a very low-level library: a minimal block, encapsulated behind an.mliexposing a safe API, a comment demonstrating the representation invariant, and a test.Obj.magicto "shut the compiler up" is a bug waiting for a date.- opam ≥ 2.5.2 as a security minimum. Verified: OSEC-2026-10 / CVE-2026-57825 — escape from opam's installation sandbox via symbolic links (
.installfiles did not check the symlink resolution of the destination), fixed in opam 2.5.2 (2026-07-08); and CVE-2026-41082 (Debian's DSA-6216-1), insufficiently restricted.installdirectives that allowed escaping the package's area. Correct reading:opam installruns third-party code on your machine and on your CI runner; the sandbox is a mitigation, not a barrier. - opam dependency auditing — the real state, unvarnished. The OCaml Security Advisory Database exists (
github.com/ocaml/security-advisories), maintained by the OCaml security team, feeding osv.dev; the repository itself describes itself as work in progress. As of Aug 2026 there is NO publishedopam auditcommand: it is proposed and under discussion, not delivered. Operational criteria in the meantime: consume the database via OSV in the CI gate (a generic scanner that reads OSV), subscribe to the OCaml security announcements channel, and pin the switch with a versionedopam.lockedso the surface is known and reviewable. Verify the state of the tooling before writing the gate (§8). - FFI (C stubs): a memory boundary with no safety net. Specific review,
[@@noalloc]only if it is true, and remember that effects do not crosscaml_callback. - SQL parameterised only (
caqtiand similar). No concatenating queries.
F#
- The security of the .NET stack —NuGet and its auditing, secrets, ASP.NET Core, headers, container hardening— belongs to
dotnet-standards. Here only what is language-specific: - Deserialisation:
System.Text.Jsonwith concrete types. Discriminated unions do not serialise in an obvious way — they require an explicit converter (FSharp.SystemTextJsonor equivalent); improvising a DU format at the edge of an API is a classic source of incompatibility and of accepting unexpected payloads.BinaryFormatteris forbidden as is all open polymorphic deserialisation. - Nulls as a security problem, not a style one: a
nullcoming in from C#/BCL into an F# value whose type declares it non-nullable produces aNullReferenceExceptionin the wrong place, and in the worst case skips a validation. Normalisation at the edge (§3) is a control, not a courtesy. - NuGet auditing: set by
dotnet-standards(NuGetAudit, lockfiles, CPM). What this skill adds: F# ecosystem libraries are small community projects — before pinning one, verify maintenance, licence (by reading the realLICENSE, not the package field) and whether it has a replacement in the BCL. Precedent from the catalogue: tools that change licence or declare themselves feature complete with a commercial action.
[Both]
- Validation at the edge with types: the smart constructor returning
Result/optionis the input control. No "the layer above will validate it". - Secrets never in the tree, in logs or reachable by a
%A/automatic printing derivation: types containing credentials carry explicit redacted printing. - Crypto: a maintained library from the respective ecosystem, verified at the time (§8). Nothing homegrown, no MD5/SHA-1/DES/ECB, TLS 1.2+.
6. Performance and operability
OCaml
- Domains ≈ cores. The number of domains is sized by available CPUs and is adjusted to the container's limit, not to the physical machine; over-subscribing domains degrades through GC contention. Concurrency for many tasks is done with fibres/effects inside the domain (Eio), not with one domain per task.
- State shared between domains:
Atomic,Mutex, or concurrent structures from a library, with the dependency's safety verified explicitly (§2). Compile and run code that shares state between domains with TSan; there is support and it was used to fix real bugs in the runtime itself. - GC: OCaml's generational GC has short pauses by design, but you measure before claiming it. Levers (
OCAMLRUNPARAM:sminor heap size,omajor overhead) only with measurement, not copied from a blog. - Profiling:
perfover the native binary,landmarks/memtracefor allocation.dune build --profile releasefor production; thedevprofile carries assertions and less optimisation. - Compiling to JavaScript:
js_of_ocaml— it compiles the bytecode to JS and is the mature route for reusing an OCaml domain in the browser (orwasm_of_ocamlfor WebAssembly; verify maturity before committing — §8). Criteria: sharing the domain core between server and client is the legitimate use case; writing an entire SPA in OCaml is a team decision of the same calibre as §7. Bundle size is measured and watched in CI. - Observability: structured logging (
logswith a JSON reporter) and process metrics; verify the state of the OpenTelemetry binding before committing (§8). Explicit timeouts on every network operation; structured cancellation with Eio (Switch) orLwt.cancelcarefully. Graceful shutdown withSIGTERMhandling that drains before closing.
F#
- Almost everything operational is set by
dotnet-standards(observability, health checks,HttpClienttimeouts and resilience, limits, graceful shutdown, container, AOT). What is language-specific: task { }overasync { }in new code: fewer allocations and a direct boundary with theTaskof the rest of the ecosystem. Convert at the edge, do not sprinkle conversions through the code.- Allocation: records, discriminated unions and tuples are reference types by default; on measured hot paths, consider
[<Struct>]on small DUs and records, andstructtuples. Never without BenchmarkDotNet in front. - F# lists (
list) are immutable linked lists: excellent for the domain, bad for large collections with indexed access or built by repeated concatenation.array/ResizeArrayinside a hot function is acceptable if the result comes out immutable. Seqis lazy and can be enumerated twice: materialise before reusing; aSeqover an already closed resource is a classic bug.- Compilation:
ParallelCompilation(§2) reduces build time in large projects; verify the default for your SDK version before assuming it.
7. Long-term sustainability
- Team risk, with different profiles. OCaml: a small community, hard hiring, and most of the industrial ecosystem gravitating around one company (Jane Street) whose libraries are excellent but define a dialect. The same bar applies as in
haskell-fp-standards: bus factor ≥3 and a training budget before approving the stack. F#: the risk is lower because the runtime, the tooling, the hosting and .NET hiring are mainstream; the real risk is ending up as the only F# project in a C# department, with nobody to review it or maintain it. Honest mitigation: F# wins when the team is already .NET and the problem is domain modelling; if there is not one more person who reads F#, the answer is C# with good modelling. - Upgrade cadence. OCaml: current minor + patch without delay; the minor jump is tested first because of the
ppxes and the opam dependencies, which are the real bottleneck.opam.lockedversioned and updated in a PR of its own, not mixed with features. F#: the cadence is set by the SDK and is fixed bydotnet-standards(LTS to LTS); what belongs here is reviewing the new warnings each version of the F# compiler brings — F# 10's attribute target validation is the example: errors appear where there used to be silence, and fixing them is the right thing. - Documentation as mitigation:
.mlidocumented with odoc in OCaml, signatures and XML doc in F#; ADRs for the structural decisions (Stdlib versus Core; Eio versus Lwt; type providers yes or no; Expecto versus xUnit) and aCONTRIBUTINGthat starts from zero (opam switch create . --deps-only/dotnet restore). - Conscious debt: every shortcut with
TODO(user): reason — issue. No warning silenced without a comment and a link.
PROHIBITIONS (an exception requires an ADR and approval).
- ❌ [OCaml]
Marshal.from*/input_valueover data crossing a trust boundary. It is code execution. The hardening in 5.4.1/4.14.3 does not make it safe. - ❌ [OCaml]
Obj.magicand theObjmodule in application code;Obj.magicto silence the compiler. - ❌ [Both] The
_wildcard as a default branch over a domain union/variant; disabling the exhaustiveness warning (OCaml warning 8, F# FS0025) at project level. - ❌ [Both] Warnings without
-warn-error/TreatWarningsAsErrors; global suppression of a warning that is resolved by bounding it. - ❌ [OCaml] A project without
opam+dune; hand-crafted Makefiles overocamlfind;ocamlbuild. - ❌ [OCaml]
.ocamlformatwithout the tool version pinned; a public module without an.mli. - ❌ [OCaml] Mixing Lwt, Async and Eio in the same binary without an explicit, documented bridge; writing your own effect handlers without an ADR.
- ❌ [OCaml] Assuming a library is domain-safe without checking; sharing state between domains without
Atomic/Mutexand without passing TSan. - ❌ [OCaml] Functorising with a single implementation; global
openof large modules. - ❌ [OCaml] opam < 2.5.2 (sandbox escape, §5); OCaml 4.x in a new project; installing dependencies in CI without a versioned lock.
- ❌ [F#]
Option/discriminated unions exposed raw in an API meant for consumption from C#, without a test from C#. - ❌ [F#] Consuming values from C#/BCL without normalising nulls at the edge.
- ❌ [F#]
#r "nuget: X"without a version in scripts; a production.fsxnot promoted to a project with tests. - ❌ [F#] Type providers in compiled production code without an ADR (they couple the build to an external resource).
- ❌ [F#] Your own computation expression without an ADR; a CE for what
|>+Result.bindsolves. - ❌ [F#]
BinaryFormatter; DUs serialised without an explicit converter. - ❌ [F#] Deciding here what
dotnet-standardsdecides (SDK, LTS, NuGet, ASP.NET Core, container): it is a boundary violation, not a shortcut. - ❌ [Both]
string/bool/intas a substitute for a domain type when a closed variant exists. - ❌ [Both] Property-based testing absent in code with invariants; a case reduced by shrinking not frozen as a regression.
When NOT to choose these languages (honest prohibition).
- ❌ OCaml when there is no bus factor ≥3, when the team cannot sustain a small ecosystem, or when the project depends on first-line cloud/ML SDKs that do not exist in OCaml or are third-party wrappers. Nor if the work is infrastructure glue →
go-standards/python-standards. - ❌ OCaml if the requirement is parallelism with mature concurrent structures "out of the box": OCaml 5 gives you the runtime, but the ecosystem still assumes a single thread in many places.
- ❌ OCaml and F# for hard latency / real time: both have a GC →
rust-standards. - ❌ F# when there is no second person in the organisation capable of reading and reviewing it. An isolated F# service in a C# house is organisational debt, however good the solution is.
- ❌ F# if the real decision is "I want functional on .NET" without a modelling problem that justifies it: modern C# with records, patterns and nullable references covers a lot.
- ❌ Either of the two chosen "because the team wants to learn" in a system with an SLA.
8. Mandatory web verification
Before pinning a version, a flag or claiming the state of the ecosystem, verify on the web (not from memory):
- Current OCaml and the state of 4.x:
ocaml.org/releasesandocaml.org/changelog; announcements ondiscuss.ocaml.org. Is 5.5.0 still the latest? Has 5.6 come out? Has 4.14 maintenance ended (announced at least until the end of 2026)? - What is really stable in OCaml 5 today: the official manual for the specific version (
ocaml.org/manual/5.x/effects.html) — re-read the sentence about effect safety before claiming anything; as of Aug 2026 effects are not typed and there is no calendar for them to be. Also check the state of multi-shot continuations and the restrictions oncaml_callbackand signals. - OCaml and opam security:
github.com/ocaml/security-advisoriesand OSV. Verified as of Aug 2026: OSEC-2026-01 / CVE-2026-28364 (Marshal, fixed in 5.4.1 / 4.14.3) and OSEC-2026-10 / CVE-2026-57825 (opam sandbox escape, fixed in opam 2.5.2, 2026-07-08). Check whetheropam auditalready exists — as of Aug 2026 it is proposed but not delivered, and that is the gap in the SCA gate. - OCaml tooling: dune (3.24.0, 2026-06-30), ocamlformat (0.29.0, 2026-03-17), opam (≥2.5.2), Merlin (5.7.1-504) and ocaml-lsp (1.26.0). Merlin is paired to the compiler version: check the suffix.
- OCaml concurrency: Eio (1.4, 2026-07-23) and Lwt (6.1.2 on opam, 2026-04-29, MIT). Lwt's release feed mixes the 5.x and 6.x lines — cross-check with
ocaml.org/p/lwt, which is the correct source. State and maturity of Async outside the Jane Street ecosystem. - F# and its SDK: the F# version comes with the SDK — verify on
learn.microsoft.com/dotnet/fsharp/whats-newwhich one the current SDK brings (as of Aug 2026: F# 10 with .NET 10 LTS) and that SDK's support dates indotnet-standards, not here. Confirm theParallelCompilationdefault in the specific version and the state of nullable reference annotation in F#. - F# tooling and licences: Fantomas — verified Apache-2.0; version discrepancy declared in §2 (NuGet 7.0.5 of Dec 2025 versus GitHub releases of Apr 2026): check both sources before pinning. Expecto (11.1.0, 2026-06-17) and FsCheck (3.3.4, 2026-07-25): versions verified, licences NOT verified. FsUnit and FsToolkit: Declared gap. Precedents that force you to look at the licence and the maintenance mode before pinning a tool: Trivy (licence change), gitleaks (feature complete + an action with a commercial licence for organisations since v2).
Declared gaps (not verified as of Aug 2026, do not fill from memory):
- Versions and maintenance state of Alcotest, QCheck, ppx_expect, bisect_ppx, odoc, yojson, caqti, Dream/opium and of the OpenTelemetry binding for OCaml.
- State and licence of Jane Street
Base/Core/Asyncand their current version; the arguments in §2 about adoption cost come largely from community discussion predating 2022 and must be reconfirmed with current data (e.g. reverse depe
…(truncated)