Enforce Trust Boundaries in OCaml with Abstract Types
Use OCaml module signatures and abstract types so untrusted input can only reach domain logic through validated constructors.
19 Aug 2025, 06:19 UTC

The problem: untrusted input reaches domain logic as plain values
A network handler reads a byte buffer, parses it into a record, and passes that record to business logic. If the parser misses a single invariant—an empty username, a negative balance, or a timestamp in the future—the domain layer processes the value anyway. OCaml can stop this class of bug at compile time by making invalid states unrepresentable outside the module that creates them.
The useful takeaway: hide the constructors of your domain types behind module signatures. Expose only smart constructors that validate input, and let downstream modules consume the resulting abstract type without knowing how it is built.
Requirements
- Input arrives as untrusted bytes or strings from outside the process.
- Domain values must satisfy invariants before business logic sees them.
- Downstream modules must not be able to forge, pattern-match, or reconstruct those values.
- Validation failures must be explicit results, not exceptions used for control flow.
Smallest suitable design
Use three modules. The first holds raw input. The second defines an abstract type and validates values into it. The third consumes the validated type.
Module responsibilities
| Module | Responsibility | Exposes |
|---|---|---|
Input | Holds raw bytes or strings with no validation. | Raw type only |
Validate | Parses raw input and returns an abstract validated type. | type t and smart constructors |
Domain | Runs business logic on values it cannot construct itself. | Functions taking Validate.t |
Example code
(* validate.mli *)
type t
val parse_username : string -> (t, string) Result.t
val username : t -> string
(* validate.ml *)
type t = string
let parse_username s =
if String.length s = 0 then Error "empty username"
else Ok s
let username t = t
(* domain.ml *)
let greet user =
"Hello, " ^ Validate.username user
Where to run: a terminal in the project root, using the OCaml compiler or a Dune build. Required permissions: read and write access to the source files. Placeholders: replace Validate with your actual module name. Expected check: the compiler accepts greet only when the argument comes from Validate.parse_username. Risk: none from compilation itself; a buggy validator can still admit bad data.
Because t is abstract in validate.mli, code outside Validate cannot write let forged : Validate.t = "raw". The type checker rejects it because string is not compatible with the abstract type t.
Trust and data boundaries
The first boundary is between raw input and the validator. Bytes enter Input, and only Validate may convert them into t. The second boundary is between Validate and Domain. Domain receives t as an argument but cannot inspect its representation or manufacture a new one.
If data crosses a process boundary—such as writing t to disk or sending it over the network—serialization creates a new trust boundary. The deserializing side must run validation again; an abstract type does not travel with its guarantees across a byte stream.
Operational checks
- Compile a test module that attempts to construct
Validate.tdirectly from a string. Confirm the compiler rejects it. - Review
validate.mlito ensure it exports no value constructors, only functions returningtorResult.t. - Feed random or malformed strings to
parse_usernameand confirm it returnsErrorwithout raising an uncaught exception. - Confirm that
Domainnever pattern-matches ontand never callsValidateinternals beyond the exposed signature.
Compiler check: abstract type cannot be forged
(* domain_test.ml *)
let forged : Validate.t = "not validated"
Run ocamlc -c domain_test.ml from the project root. Expected output: a type error stating that string is not compatible with Validate.t. If the compiler accepts it, the signature is leaking the representation, usually through include or an overly permissive .mli.
Interface check
Open validate.mli and confirm that type t appears without a definition and that no constructor values are listed. The compiled validate.cmi enforces this abstraction at link time, but the readable contract is the .mli file itself.
Fuzz-style check
Write a small runner that passes generated strings—empty strings, very long strings, strings with control characters—to parse_username. Assert that every call returns either Ok with a valid value or Error, and that no exception escapes. This checks that validation is total with respect to its error type.
Failure modes
- Signature leakage: using
includeoropenin a way that exposes constructors or the underlying representation oft. - Exceptions for control flow: raising
FailureorInvalid_argumentinside validation lets errors bypass theResultpath and can reachDomainunhandled. - Mutable state in the validator: storing validated values in a reference breaks referential transparency and can let stale or forged values leak out.
- Structural comparison: using polymorphic
=on abstract types can expose representation details if the type is later instantiated differently in another module.
Conditions that change the design
- If you need runtime extensibility, replace fixed modules with functors or first-class modules so validation rules can be injected.
- If data crosses process boundaries, add a deserialization step that re-validates bytes into the abstract type; do not trust stored or transmitted values.
- If allocation for wrapper types is unacceptable in a tight loop, consider a single-pass parser that produces domain values directly, but accept that the compile-time boundary becomes weaker.
Limitations and practical verification
Module signatures enforce boundaries, not correctness. A buggy Validate module can still admit invalid values. Abstract types do not replace testing, fuzzing, or code review of the validator itself.
Practical verification: maintain the domain_test.ml forgery test in your continuous integration. If a future change to validate.mli exposes constructors, the test will fail to compile and the boundary breach will be caught before deployment.
No runtime state is changed by adopting this design, so there is no rollback procedure. Revert the source files if the abstraction proves unsuitable for your performance or extensibility needs.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.