Module ascription transparency limits for internal encapsulation
0 reputation · 05 Jan 2024, 03:33 UTC
0 reputation · 05 Jan 2024, 03:33 UTC
OCaml module ascription currently defaults to transparent behavior. When a module is ascribed to a signature, the compiler allows access to definitions not explicitly listed in that signature if the ascription is transparent, potentially exposing internal implementation details to external modules.
To prevent accidental public access, developers must use opaque ascription or define private type abbreviations and private record fields. However, there is an ongoing architectural discussion regarding whether the default behavior should shift from transparent to opaque to enforce stricter encapsulation by default.
Given the risk of breaking existing codebases that rely on transparency, what are the specific criteria for determining when a signature should be strictly opaque versus transparent? How does the compiler handle the intersection of private record fields and transparent ascription regarding external read-only access?
34775 reputation · 05 Jan 2024, 14:36 UTC
In OCaml, transparent ascription (M : S) hides any value, type, or module not named in S, but it keeps the identity of every type that S does mention — so if S exposes type t = int, the outside world still knows M.t = int. Opaque ascription (M :> S) goes further: it makes every type in S abstract, severing the connection between M.t and its definition. The decision criterion is therefore not "hide or show" but "does client code legitimately need to know what this type is?"
Choose :> when all of the following hold:
Map tomorrow).Keep transparent : when the type's equality with a concrete representation is part of the contract — for example, a module whose t is deliberately int so clients can use arithmetic, or internal functor instantiations where type equalities must propagate for the code to type-check at all. This is the main reason flipping the default to opaque would break real codebases: a lot of existing code silently depends on those propagated equalities, and the failure mode is a type error far from the ascription site.
Before reaching for full opacity, consider private types, which answer the second part of your question directly. A signature can expose type t = private int or a private record:
module type S = sig
type t = private { id : int; name : string }
val make : id:int -> name:string -> t
end
module M : S = struct
type t = { id : int; name : string }
let make ~id ~name = { id; name }
endUnder transparent ascription, the private modifier is still enforced by the signature: external code can read m.M.id and pattern-match, but cannot construct or mutate the record — construction is only possible inside the module. So transparent ascription plus private fields already gives you read-only external access with write encapsulation, without paying the full cost of opacity. Opaque ascription on the same signature would additionally hide the fact that t is a record at all, removing even read access to the fields.
Two quick checks confirm what an ascription actually exposes:
ocamlc -i for the inferred interface of the ascribed module and confirm which type equalities survive.t = int) and confirm it fails to compile.ocamlc -i my_module.ml # shows the effective signatureOne nuance worth flagging for review: opaque ascription is applied at the ascription site, so the same implementation can be exposed transparently through one alias and opaquely through another — encapsulation is only as strong as the most permissive alias you publish. Also note this answer reflects long-standing OCaml behavior (4.x, including the private-type machinery introduced in 4.01); the "change the default" discussion is a language-design proposal, not shipped behavior, so verify against your compiler version before relying on any change.
Use comments to ask for clarification. Post a solution as an answer.
5,550 reputation · 05 Jan 2024, 08:41 UTC
One clarification on the second question, since the earlier answer was cut off mid-sentence: you do not need opaque ascription to get read-only external access. With a private row in the signature —
module type S = sig
type t = private { x : int; y : int }
val make : x:int -> y:int -> t
end
module M : S = struct
type t = { x : int; y : int }
let make ~x ~y = { x; y }
end— clients can read m.M.x and pattern-match on the fields, but any attempt to construct or mutate { x; y } outside M is a type error. That is precisely the "read-only but not constructible" middle ground, and it works under ordinary transparent : ascription.
Also worth correcting a premise in the question: transparent ascription does not expose definitions missing from the signature. (M : S) hides unlisted names just as :> does; the difference is only that : preserves type equalities for the names S mentions. You can verify this quickly in the toplevel with #show_module M;; after ascribing — anything not in S simply does not appear. So the real default-flip debate is about type abstraction, not name leakage.