Find and remove dead code and unused APIs in OCaml projects

Coding guidelines#

1 Overall philosophy#

  • Strive for small, orthogonal modules that can be composed freely.
  • Depend only on the standard library (and occasionally on other “micro-libs”, e.g. Fmt, Rresult).
  • Preserve purity and determinism in the core; confine effects to thin I/O layers.
  • Offer total functions whenever feasible and expose explicit failure via 'a result.
  • Maintain API stability: once an identifier is public, avoid breaking changes; add instead of mutate.

2 Project layout#

my_lib/
├─ dune-project
├─ README.md        – high-level overview & minimal example
├─ CHANGES.md       – human-written change-log
├─ lib/             – library source code (alternative to src/)
│  ├─ my_lib.mli    – canonical public interface
│  └─ my_lib.ml     – implementation
├─ bin/             – executable entry points
└─ test/            – Alcotest or inline-tests

Put the public interface in a single .mli whenever practical. Split only when the conceptual surface really warrants distinct compilation units. Using lib/ and bin/ directories instead of src/ is acceptable for projects with both libraries and executables.


3 Modules and sub-modules#

Purpose Idiom
Canonical type type t (abstract)
Pretty printer val pp : t Fmt.t
Conversions val to_string : t -> string
val of_string : string -> (t, error) result (when meaningful)
Equality & order val equal : t -> t -> bool
val compare : t -> t -> int (when meaningful)
Constructors val v : ... -> (t, error) result (or pure t if infallible)
Low-level view module Unsafe : sig … end (optional)

Keep the root module flat; introduce nested modules only for clearly separable concerns (e.g. My_lib.Path, My_lib.Map).


4 Naming conventions#

  • Modules / files – lower-case, underscore-separated file names (my_lib.ml), capitalised module names (My_lib).
  • Values – short but descriptive (pp, v, find, with_…).
  • Labels – prefer them only when they disambiguate (~dir, ~mode); avoid gratuitous labels on simple data.
  • Boolean arguments – almost never positional; wrap in a variant or use a labelled argument (?dry_run:bool).
  • Infix operators – expose only if widespread (>|=) or central to the library (Fpath.( / )). Put them in a dedicated sub-module Op.

5 Types#

  • Keep representation hidden (type t) and provide construction/destruction helpers.
  • Introduce phantom parameters to encode invariants if it avoids run-time checks.
  • Use private types when callers may inspect but not forge values (type t = private string).
  • For enumerations, use a closed variant; supply val all : t list and val to_string.
  • Reserve exceptions for programming errors (Invalid_argument, assertion failures). Operational errors travel in the 'error part of ('a, error) result.
  • Formatting: Use Fmt consistently for all output. Never mix Printf and Fmt - use Fmt.pr for user-facing messages, keep Printf.printf only for TTY progress display.
  • Regular expressions: Use the Re DSL instead of Re.Perl. Prefer Re.(compile (seq [...])) over Re.Perl.compile_pat.

6 Error handling pattern (Rresult)#

Base Error Types#

type error = [ `Msg of string | `Build_error of context ]
val pp_error : error Fmt.t
val v : ... -> (t, error) result

Error Helper Functions Pattern#

Start implementation files with error helper functions (err_*) for consistent error messages:

(* Error helper functions *)
let err fmt = Fmt.kstr (fun e -> Error (`Msg e)) fmt

let err_file_read file msg = err "Failed to read %s: %s" file msg
let err_file_write file msg = err "Failed to write %s: %s" file msg

Use %a with pretty printers for complex formatting:

let pp_build_error ppf ctx = Fmt.pf ppf "%s" ctx.output
let err_build_failed ctx = err "Build failed:@.%a" pp_build_error ctx

For structured errors with context:

let err_build_error ctx = Error (`Build_error ctx)

Library vs Main Separation#

Critical Rule: Library code never calls exit directly - only returns errors for main.ml to handle.

(* In library code - ALWAYS return structured errors *)
let handle_build_failure ctx =
  (* Display specific context *)
  Fmt.pr "Loop detected - stopping to prevent infinite iterations.@.";
  (* Return error for main.ml to handle *)
  err_build_error ctx

(* In main.ml - handle all exit logic *)
match analyze mode root_dir files with
| Ok result -> result
| Error (`Build_error ctx) -> 
    System.display_build_failure_and_exit ctx  (* Exits with build's code *)
| Error e -> 
    Format.eprintf "Error: %a@." pp_error e; 
    exit 1  (* Generic error code *)

Error Display Consistency#

Use unified display functions for consistent UX:

(* In system.ml *)
let display_build_failure_and_exit ctx =
  let all_warnings = (* parse build output *) in
  Fmt.pr "%a with %d %s - full output:@."
    Fmt.(styled (`Fg `Red) string) "Build failed"
    (List.length all_warnings)
    (if List.length all_warnings = 1 then "error" else "errors");
  Fmt.pr "%a@." pp_build_output ctx;
  let exit_code = get_build_exit_code ctx in
  exit exit_code

Guidelines#

  • Accept a caller-provided buffer (Buffer.t) or formatter when the operation might produce extensive diagnostics.
  • Re-use Fmt.failwith/Fmt.invalid_arg for internal invariants; never expose raw Printexc traces.
  • Never mix exit logic in library code - always return structured errors that main.ml can handle appropriately.

7 Public API surface#

  1. Minimal yet composable. Do not wrap the entire Unix API—wrap just enough to remove boilerplate.
  2. Expose first-class values rather than functors where a closure suffices.
  3. Keep effectful helpers separate (My_lib_unix, My_lib_lwt) to avoid dragging unwanted dependencies.

8 Interface documentation (.mli, .mld)#

Document every public item in the .mli. Place docstrings after the item unless it spans multiple lines, using code-first voice.

8.1 Structure and style#

(** {1 Overview}

    One concise paragraph explaining the abstraction.  Link to the README
    for a tutorial.

    {2 Types}

    {3 Constructors}

    {4 Accessors}

    {5 Derived combinators}

    {6 Pretty-printing}

*)
  • Use section headings ({1 …}, {2 …}) to group logically related functions.
  • Provide usage examples in [{[ … ]}] blocks that compile under odoc-latency-level:normal.
  • Annotate complexity (@since, @deprecated, @raise, @see) rigorously.
  • Keep comments declarative: what and guarantees, never implementation detail.

9 Testing & auxiliary artefacts#

  • Unit tests with Alcotest (test/) mirroring the public API sections.
  • Cram tests: Use directory-based cram tests (test/name.t/) instead of inline cat EOF commands for complex file creation. This is cleaner and easier to maintain.
  • Provide a tool/ directory of executable samples that double as integration tests.
  • Ship an odig metadata file so odig odoc builds HTML docs out-of-the-box.

10 Versioning & release process#

  • Tag releases with vN.N.N; follow semantic versioning.
  • Maintain CHANGES.md with user-visible entries only.
  • Publish through topkg or Dune’s built-in release workflow; push docs to gh-pages via odoc.

11 Style Checklist#

Step Done
src/<lib>.mli written first, fully documented
All errors returned as 'a result with Rresult helpers
pp, equal, compare, hash supplied where meaningful
No hidden global side-effects; pure core separated
Example snippets compile under dune build @doc
Dependency list reviewed: stdlib ± {Fmt, Rresult, Alcotest}
test/ provides Alcotest suites mirroring API sections

Tick every box before publishing.