cswork / libraryOpen practice workspace

Articles

OCaml Interview Questions: Pattern Matching, Modules, and Persistent Data

Explain OCaml variant pattern matching, exhaustive cases, module signatures, functors, and persistent maps through a worked order-state example.

An OCaml interview question may ask you to add one new order state to a small program. The best answer is not merely a working match expression. You should explain what the type allows, which matches must change, what a module signature exposes, and whether a data update mutates an existing value or returns a new one. Pattern matching, modules, and persistent data form a coherent design story when you use one concrete example.

Suppose an order can be Pending, Paid of receipt_id, or Cancelled of reason. A service stores orders in a map and exposes operations through a module. The interviewer adds Refunded of refund_id. A thoughtful candidate checks compiler warnings, public API contracts, and old snapshots before writing the new branch. Practice the type-design part with Implement toy-language types and generic substitution.

Evidence boundary: Official facts about OCaml variants, matching, modules, and maps come from current OCaml documentation checked September 2026. Candidate reports do not establish any specific employer exercise here. Inference is the order example and the recommended tests and structure; your system may need different state or storage rules.

What does pattern matching buy you?

OCaml variants represent a finite set of named alternatives, and a constructor can carry data. In the example, Paid of string cannot be confused with Cancelled of string even though both carry a string. A match makes the branch for each constructor explicit:

type order_state =
  | Pending
  | Paid of string
  | Cancelled of string

let display = function
  | Pending -> "awaiting payment"
  | Paid receipt_id -> "paid: " ^ receipt_id
  | Cancelled reason -> "cancelled: " ^ reason

The compiler can warn when a match omits a possible constructor. That makes adding Refunded of string an intentional review event: find the relevant matches and decide what refund means for each operation. OCaml's documentation also notes that exhaustiveness is reasoned from the type, not from an arbitrary numerical fact the compiler might infer about a calculation. A match over a closed variant gives stronger coverage information than a match over all integers with a few listed values.

ChangeNaive responseBetter interview response
Add RefundedAdd _ -> "other" everywhereDecide refund behavior at each consumer and keep useful exhaustiveness warnings
Add payload to PaidIgnore the changed shapeUpdate constructor patterns and API contract
Match a list with [x; y] in letAssume length is always twoUse an explicit match and handle other lengths
Use a guardTreat it as part of compiler exhaustiveness proofTest the guard branch and fallback explicitly

An underscore catch-all is sometimes appropriate at a boundary that deliberately accepts future cases. But it can also hide a new constructor from compiler warnings. If every new state should force a business decision, enumerate the variants rather than swallowing them. OCaml's programming guidelines recommend using destructuring let only when the pattern is exhaustive; for uncertain shapes, use match ... with so the failure case is visible.

How should you handle a new state without breaking semantics?

Adding Refunded is not just syntax maintenance. Ask whether an order can be partially refunded, whether refunding a cancelled order is legal, and whether an external refund ID is required. If partial refunds are possible, Refunded of string may be an oversimplification; a separate payment record or richer state might be needed. The type should represent valid domain distinctions, not merely all strings seen in a database column.

Then inspect consumers. A can_ship function should likely return false for Refunded; a reporting function may count it separately; a serializer needs a stable external representation; a transition function must reject illegal moves. Put these decisions into tests. A compiler warning points to code that needs attention, but it cannot decide business policy. Exhaustive matching is a tool for finding omissions, not a proof that every branch has the right meaning.

Consider a transition function that returns result rather than throwing for an expected invalid transition:

let refund order refund_id =
  match order with
  | Paid _ -> Ok (Refunded refund_id)
  | Pending | Cancelled _ | Refunded _ -> Error `Invalid_transition

This example assumes the updated variant includes Refunded. It also assumes one full refund; a real accounting model may need more detail. The interview point is that the return type forces the caller to consider a success or explicit error, while the match enumerates source states. If the interviewer changes the domain requirement, revise the type and the transition table rather than patching one string comparison.

What does a module signature hide?

OCaml modules group definitions and provide a boundary around implementations. The official modules guide explains that a module signature specifies what clients can access; definitions omitted from the signature stay hidden. A .mli interface or explicit sig ... end can expose operations while keeping a representation abstract. This lets you change the underlying map or validation logic without requiring callers to manipulate internal values.

For an order store, an interface might say:

module type ORDER_STORE = sig
  type t
  val empty : t
  val add : string -> order_state -> t -> t
  val find_opt : string -> t -> order_state option
end

The abstract type t prevents callers from assuming that the store is a list, a map, or a hash table. The function signatures also communicate a functional update: add accepts a store and returns another store. That alone does not tell you how the implementation uses memory or whether the result is written to disk; it tells you what callers may rely on. If you later choose a different persistent structure, the signature can remain stable.

The modules guide describes functors as functions from modules to modules. Map.Make(String) is a concrete example: it takes a module that defines key ordering and returns a map module for those keys. In an interview, distinguish this from a value-level List.map; they operate at different abstraction levels. You do not need to invent a functor for every small program, but you should be able to explain why Map.Make is an API for reusable type-safe maps.

What does persistent data mean here?

OCaml's Map documentation describes an immutable key-value structure implemented with a balanced search-tree approach and logarithmic lookup. Adding a key returns a new map value; the old map remains available. This is persistence of versions in memory, not automatic durability on disk. A candidate who says “the map is persistent, so a crash cannot lose it” confuses two meanings of the word.

module Orders = Map.Make(String)

let before = Orders.empty
let after_payment = Orders.add "order-42" (Paid "r-9") before
let after_refund = Orders.add "order-42" (Refunded "f-3") after_payment

before is still empty, after_payment still contains the paid state, and after_refund contains the refunded state. The returned maps can share internal structure, so keeping old versions does not necessarily copy an entire tree on every update. Still, retaining many snapshots has a memory cost, and updates allocate new nodes. The implementation's performance should be assessed with the actual number of keys, update rate, and snapshot lifetime.

ValueLogical content for order-42What it demonstrates
beforeNo entryOriginal map is unchanged
after_paymentPaid "r-9"First update returns a new value
after_refundRefunded "f-3"Later update does not overwrite the earlier snapshot

This is useful for undo, speculative calculations, and comparing versions. For example, a planner can calculate a candidate refund view while keeping the prior view for validation. But if the application must survive process death, it also needs durable storage or a log. The persistent map alone lives in process memory.

When is a persistent map the wrong tool?

If a hot path updates a huge mutable counter set with no need for older versions, an imperative hash table or specialized structure may be more appropriate. OCaml supports mutation; functional style is not a requirement to avoid every mutable value. Ask about workload, contention, memory budget, and API safety. A persistent map is attractive when immutable snapshots simplify reasoning or concurrent readers can share an old value safely. A mutable table can be efficient when one owner updates it and the mutation boundary is controlled.

Do not make a false complexity claim. Map operations are logarithmic in key count; an old version can share nodes with a new one, but each update is not generally constant-time and keeping every version forever is not free. If the interviewer asks about iteration order, note that ordered maps use key comparison. If the comparison function changes or keys have subtle ordering, the module supplied to Map.Make is part of the structure's invariant.

The module signature can hide either implementation. If callers receive only empty, add, and find_opt, changing the internal storage strategy is possible as long as the observable semantics and complexity expectations remain acceptable. That is a better abstraction than exposing a raw Hashtbl.t while promising a persistent update contract.

How would you test the complete exercise?

Test each constructor's business behavior. For display, assert exact output for Pending, Paid, Cancelled, and Refunded. For transitions, test valid Paid -> Refunded and invalid paths such as Pending -> Refunded. If a new variant constructor is added later, configure the build to surface non-exhaustive match warnings and review them. Tests cover semantics; warnings highlight missing branches.

Test the module boundary by compiling a client that uses only the signature. A client should not be able to reach hidden implementation details. Then test version persistence: create before, after_payment, and after_refund; assert the content of all three after the last update. This catches an accidental mutation if the implementation changes.

Finally, test the crash boundary separately. Serialize or store the intended durable state, restart the application, and verify recovery from that durable source. Do not treat an in-memory map snapshot as a disaster-recovery mechanism. A good interview answer can say precisely which guarantee comes from the type checker, which comes from the module boundary, which comes from immutable data, and which still requires external persistence.

Which explanations are incomplete?

“Pattern matching is exhaustive” is incomplete unless you discuss wildcards and actual business meaning. “Modules are just namespaces” misses signatures and abstraction. “Persistent means saved forever” is wrong for an immutable in-memory map. “Functional is always faster” ignores allocation, workload, and alternative structures. The strong answer connects each language feature to a checkable invariant in the order example.

Sources and Further Reading