Blog › ICP guides

Standard ML developer on retainer: SML/NJ, MLton, module system functors, pattern matching, and functional systems programming on monthly retainer

October 6, 2026 · ~20 min read

A bioinformatics pipeline compiled with MLton was producing incorrect sequence alignment scores for approximately 1 in 800 record pairs against one of four organism databases. The scoring errors were not detected by the existing test suite because the tests covered only the primary organism database. The Standard ML developer on retainer reviewed the functor instantiation. The alignment scoring system used a functor MakeAligner(Score : ALIGNMENT_SCORE) that computed edit distances using the provided scoring matrix. The ALIGNMENT_SCORE signature declared only val score : base * base -> int; it did not require symmetry (score(a,b) = score(b,a)) or that identical bases score positively. The functor was instantiated with a scoring matrix loaded from a CSV file at runtime. For one organism type, a transposed CSV file had been included in the build artifact — the matrix was correct along the diagonal (same-base matches scored correctly) but swapped all off-diagonal entries, so score(A, G) returned the value that should have been score(G, A). For a symmetric scoring matrix these would be equal. For the BLOSUM-inspired asymmetric matrix used for that organism, they differed. The ML type system verified that score accepted two bases and returned an integer. It said nothing about whether the function was symmetric. The fix: the functor startup code was extended with a runtime symmetry check across all base pairs; the transposed CSV was corrected; wrong alignment scores: 1 in 800 → 0.

The work log entry read “fixed asymmetric scoring matrix for organism-3 database, added startup symmetry validation, 9h.” It describes what was fixed and the approximate duration, leaving the client’s research team unable to explain why a CSV transpose that passed all type checks and unit tests caused 0.125% alignment score errors. The diagnosis required understanding that ML signatures constrain types but not algebraic laws, that functor instantiation is not a runtime validity check but a static type check, and that the organism-3 test cases used in CI all happened to test only symmetric-path scores. The nine hours of review, matrix comparison, test case analysis, and validation design have no corresponding artifact in the committed diff — the diff shows a corrected CSV file and a dozen lines of startup validation.

Standard ML fundamentals: val/fun/let, algebraic datatypes, pattern matching, and mutable state

Standard ML uses val for value bindings and fun for function definitions: val x = 5, fun square n = n * n, fun foldl f init [] = init | foldl f acc (x::xs) = foldl f (f(acc, x)) xs. The let ... in ... end expression scopes local bindings: let val temp = computeIntermediate x in finalize temp end. local structure X = ... in structure Y = ... end scopes local structures at the module level. Type inference eliminates most annotations, but explicit type constraints improve readability at module boundaries: val compare : base * base -> order. Algebraic datatypes use the datatype keyword: datatype 'a tree = Leaf | Node of 'a * 'a tree * 'a tree defines a polymorphic binary tree; datatype base = A | C | G | T | Gap defines a sum type. Constructors are first-class values: List.map Node is valid. Pattern matching is ML’s primary control flow mechanism: fun depth Leaf = 0 | depth (Node (_, l, r)) = 1 + Int.max(depth l, depth r). The SML/NJ compiler warns on non-exhaustive match expressions; MLton by default produces a type error. A match expression that silences warnings with a final _ => raise Fail “impossible” wildcard commits to an invariant that the compiler cannot verify — if the invariant is wrong, the runtime exception is the only signal.

Mutable state in Standard ML uses ref cells: val counter = ref 0 creates a ref cell; counter := !counter + 1 updates it; !counter dereferences it. Ref cells are the only form of mutation in the core language; arrays (Array.update) provide O(1) mutable indexed storage. Exceptions provide non-local control flow: exception Fail of string declares a user exception; raise Fail “message” throws it; someExpr handle Fail msg => recover msg | _ => raise catches it. The pattern handle _ => defaultValue swallows all exceptions including the SML standard-specified ones (Match, Bind, Overflow, Subscript) — a retainer engagement reviewing exception handling frequently finds catch-all handlers that silently suppress Match exceptions from non-exhaustive patterns, making the first observable symptom a wrong result rather than a crash. The Standard Basis Library provides Int, Real, String, Char, Bool, List, Array, Vector, TextIO, BinIO, OS.FileSys, and OS.Path modules. List.foldl is tail-recursive (safe for large lists); List.foldr is not (avoids reversed accumulator but risks stack overflow). List.rev followed by List.foldl is a common pattern: build a reversed accumulator with foldl (O(n), tail-recursive), then reverse once at the end, rather than using foldr which allocates a call frame per list element.

Equality types in Standard ML govern where op = can be applied. Types with the eqtype keyword support structural equality: int, string, bool, char, tuples and records of equality types, and datatypes with no function-valued components. Functions are not equality types; ref cells have reference equality (two refs are equal only if they are the same ref, not if they contain equal values). The equality type variable ''a (two apostrophes) constrains a polymorphic function to equality types: fun member (x : ''a) [] = false | member x (h::t) = x = h orelse member x t accepts lists of equality types but not function lists. A retainer engagement reviewing a comparison function that uses op = on a record type containing a function-valued field will find a type error at the call site, not at the definition; understanding why the equality type restriction exists and how to design around it (explicit comparison functions, functors parameterized by comparison) is module system knowledge that is invisible in the corrected code.

Standard ML’s polymorphism is prenex and inferred: fun id x = x has type ’a -> ’a; fun swap (x, y) = (y, x) has type ’a * ’b -> ’b * ’a. The value restriction limits polymorphism for mutable values: val r = ref [] is not polymorphic because allowing polymorphic ref cells would break type safety (a ref ’a list could be stored as an int list and read as a string list). When the value restriction fires, SML/NJ reports a warning that a binding has been given a less-polymorphic type than expected; the fix is usually to eta-expand: fun f x = (ref []) x instead of val f = ref []. Understanding value restriction interaction with higher-order functions and the module system is a common source of unexplained type errors in SML codebases.

Module system, functors, opaque ascription, SML/NJ CM, MLton, and CML concurrency

The Standard ML module system is one of the most powerful module systems in any mainstream programming language. A structure is a collection of types, values, and sub-structures: structure IntSet = struct type t = int list val empty = [] fun insert x s = ... end. A signature specifies what a structure must provide: signature SET = sig type t type elem val empty : t val insert : elem * t -> t val member : elem * t -> bool end. Transparent ascription structure S : SIGNATURE = ... exposes the concrete types through the signature; clients can see that IntSet.t = int list and construct values directly. Opaque ascription structure S :> SIGNATURE = ... hides the representation: clients can only use the values declared in the signature, and the concrete type is inaccessible. Opaque ascription enables true abstraction: a client cannot bypass the insert function to directly construct an unbalanced tree, because the tree type is abstract. The choice between : and :> is an architectural decision with correctness implications; transparent ascription that should be opaque is one of the most common module system misuses in a retainer audit. A balanced tree module using : TREE instead of :> TREE allows clients to construct invalid internal nodes directly, bypassing the balance invariant and producing silent corruption.

A functor is a module-level function: it takes a structure argument satisfying a signature and produces a new structure. functor MakeSort(Ord : ORDERED) = struct fun sort lst = ... end creates a sorting module parameterized by a comparison structure. The ORDERED signature typically declares type t and val compare : t * t -> order; the functor assumes compare is a total order (irreflexive, transitive, total). The type system verifies that the structure provided at instantiation time has the right types, but it cannot verify that compare is actually a total order. A compare function that returns EQUAL for two structurally different values violates the functor’s precondition; the sort result is undefined behavior within the functor’s contract, but the type system reports success. Functor instantiation audits — reviewing every application site of a parameterized functor to confirm the provided structure satisfies the algebraic laws, not just the types — are some of the most valuable and least visible retainer work in SML codebases. A signature strengthened with additional law-checking functions (val checkTransitivity : t * t * t -> bool) or runtime validation at functor instantiation catches violations that pure type-checking cannot.

The SML/NJ Compilation Manager (CM) manages multi-file SML projects. A .cm file lists source files and dependencies: group is foo.sml bar.sml; basis basis.cm = cm “$smlnj/basis/basis.cm”. CM.make “sources.cm” compiles the project in SML/NJ interactive mode. MLton is a whole-program optimizing compiler: mlton -output executable program.mlb compiles a .mlb (ML Basis) file that lists all sources. MLton performs aggressive dead-code elimination, inlining, and specialization, producing native code that typically outperforms SML/NJ’s runtime compilation. MLton also enforces exhaustiveness more strictly than SML/NJ by default and applies the value restriction more eagerly. A retainer engagement porting an SML/NJ project to MLton compilation commonly finds 4 to 12 exhaustiveness errors and 2 to 6 value restriction warnings that SML/NJ had silently accepted; each must be resolved before the MLton build succeeds. MLKit uses region inference to statically allocate many heap values, eliminating GC pauses for latency-sensitive systems programming work; the region inference annotations (at region) require ML type expertise to apply correctly.

CML (Concurrent ML) is a concurrency library for Standard ML that extends the language with synchronous message-passing channels. channel() creates a typed channel: val ch : int CML.chan = CML.channel(). CML.send(ch, value) blocks until a receiver is ready; CML.recv ch blocks until a sender is ready. Both operations are synchronous by default — there is no buffering; communication only occurs when both parties are ready simultaneously. CML.sync event synchronizes on an event value; CML.sendEvt(ch, v) and CML.recvEvt ch create event values that can be composed. CML.choose [ev1, ev2, ev3] synchronizes on whichever event becomes ready first, enabling non-deterministic multi-channel selection (similar to Go’s select statement). CML.wrap(event, f) applies a transformation function to the result of a synchronized event. CML.Thread.spawn (fn () => body) creates a new CML thread. CML thread design — deciding what each thread owns, what it communicates via channels, and how it handles exceptions — is architectural work that is entirely invisible in the final channel definitions and spawn sites.

How HourTab tracks Standard ML developer retainer hours

Standard ML retainer work produces a concentrated version of the invisible-work problem. The module system’s power means that the most important correctness decisions — whether to use opaque ascription, whether a functor’s precondition signature captures the necessary algebraic laws, whether a pattern match wildcard is actually covering an impossible case or a suppressed real case — produce minimal diffs. Adding > to a structure ascription declaration (changing : to :>) is one character; it is an architectural decision with correctness implications for every call site. Adding a runtime symmetry check to a functor startup function is a dozen lines; it is the only mechanism available to enforce a mathematical invariant the type system cannot express. A retainer engagement that prevents 1 in 800 wrong alignment scores, eliminates 3 daily Match exceptions, and closes 6 exhaustiveness warning suppressions produces a commit that looks small.

HourTab gives Standard ML developers a public retainer-hours URL they send to clients — typically research computing groups, bioinformatics teams, compiler infrastructure organizations, or functional systems programming teams — at the start of an engagement. The work log is where the technical context lives. For SML retainers, each entry should name the mechanism involved (functor signature law annotation; functor instantiation site audit; opaque ascription :> replacement; sharing type constraint addition; SML/NJ exhaustiveness warning review; _ wildcard missing case identification; handle expression swallowed exception audit; datatype illegal-state elimination; MLton gprof/perf profiling; tail-recursive accumulator conversion; Standard Basis Library operation selection; CML channel-based communication design; CML choose/wrap event combinator design; SML/NJ CM cm.make configuration; MLton whole-program compilation flag tuning), the specific functor name, signature component, or pattern expression involved, the diagnostic output, the change and why, and the before/after observable metric. SML retainers are often compared to Haskell developer retainers and OCaml developer retainers as ML-family functional programming engagements where module system design and type-driven correctness are the primary value delivered — work invisible in diffs but measured in wrong results that stop occurring. Clients who also work with F# developers on retainer for .NET functional programming encounter a module system without parametric functors but with discriminated unions and computation expressions that solve overlapping problems in different ways. HourTab’s work log bridges that explanatory gap: the entry names the functor law, the missing case, the instantiation site, and the before/after metric, so the client understands what the retainer produces even without reading ML module theory.

Track Standard ML developer retainer hours without the status emails

HourTab gives Standard ML developers a public URL per client retainer. One link, no login, live burn-down. Your clients stop asking “how many hours do I have left?” and your work log becomes the proof of value that gets the retainer renewed.

See HourTab pricing →

FAQ: Standard ML developer retainers

What does a Standard ML developer on retainer typically do?

A Standard ML developer on monthly retainer provides module system architecture (signature/structure hierarchy design; functor precondition law annotation and instantiation site audit; opaque :> vs transparent : ascription review; sharing type constraint design), pattern matching correctness (SML/NJ exhaustiveness warning review; _ wildcard audit for genuinely missing cases; handle expression review for swallowed exceptions; datatype design to make illegal states unrepresentable), pipeline performance (MLton gprof/perf profiling; tail-recursive accumulator conversion; Standard Basis Library operation selection; difference list design), and CML concurrency design (channel-based communication; send/recv/sync event semantics; choose/wrap event combinator design; CML thread lifecycle management).

What Standard ML work is most underlogged in a retainer?

Functor signature law annotation and instantiation audit (ALIGNMENT_SCORE signature declared only val score : base * base -> int without symmetry enforcement; transposed CSV for one organism type produced silent wrong scores for 1 in 800 sequence pairs; startup symmetry validation added; wrong scores: 1 in 800 → 0; 8–20 hrs invisible in signature and validation additions), SML/NJ exhaustiveness warning audit (6 case expressions with _ => raise Fail 'impossible' wildcards — one was reachable from gap character input class; Match exceptions per day: 3 → 0; 6–16 hrs invisible in pattern additions), and opaque ascription review (transparent : SIGNATURE allowing direct construction of internal representation types bypassing invariants; :> replacement; tree balance violations: weekly → 0; 8–18 hrs invisible in one character change).

What are typical Standard ML developer retainer rates?

Entry-level SML developers (1–2 years, val/fun/let/local, algebraic datatypes, pattern matching, polymorphic types, ref cells, exception handling, Standard Basis Library) bill at $80–$140/hr. Mid-level SML engineers (2–4 years, structure/signature/functor design, opaque :> vs transparent : ascription, sharing type constraints, SML/NJ Compilation Manager, tail-recursive accumulator conversion, CML channel-based concurrency, Standard Basis IO streams) bill at $130–$235/hr. Senior SML architects (4–8 years, full module hierarchy design for large compiled systems, MLton whole-program compilation and optimization, MLKit region inference, functor law annotation methodology, CML concurrent system design with choose/wrap event combinators, cross-implementation portability across SML/NJ/MLton/Poly/ML/Moscow ML) bill at $185–$330/hr. Monthly retainer ranges: $2,500–$5,500/mo for advisory retainers (15–25 hrs), $7,000–$20,000/mo for full SML platform development engagements.

What should a Standard ML developer retainer agreement include?

A Standard ML developer retainer agreement should specify: module system scope (signature/structure hierarchy design; functor signature law annotation; instantiation site audit; opaque :> vs transparent : ascription; sharing type constraints), pattern matching scope (SML/NJ exhaustiveness warning review; _ wildcard audit; handle expression review; datatype illegal-state elimination), pipeline performance scope (MLton gprof/perf profiling; tail-recursive accumulator conversion; Standard Basis Library operation selection; difference list design), concurrency scope (CML channel-based communication; send/recv/sync; choose/wrap event combinator; CML thread lifecycle; MLton vs SML/NJ CML semantics), and hour logging format (module system component, pattern or functor name, SML/NJ warning message, diagnostic output, change applied with law being enforced, before/after observable metric).

How should Standard ML developer retainer hours be logged?

Log each SML retainer session with: advisory category (functor signature law annotation; instantiation site audit; opaque :> ascription replacement; sharing type constraint addition; SML/NJ exhaustiveness warning review; _ wildcard missing case identification; handle expression swallowed exception audit; MLton gprof/perf profiling; tail-recursive accumulator conversion; CML channel-based communication design; CML choose/wrap event combinator design; SML/NJ CM cm.make configuration; MLton whole-program compilation flag tuning), the specific functor name, signature, structure, or pattern expression involved, the diagnostic output (SML/NJ warning: 'match nonexhaustive'; gprof showing 78% CPU in List.rev on non-tail-recursive accumulator; MLton type error on signature mismatch at functor application), change and why (ALIGNMENT_SCORE signature did not constrain symmetry — instantiation with transposed matrix produced silently wrong scores; functor startup validation required because the ML type system cannot express algebraic law constraints in signatures), and before/after metric (wrong scores: 1 in 800 → 0; Match exceptions/day: 3 → 0; gprof List.rev CPU: 78% → 3% after tail-recursive conversion). Include SML implementation version (SML/NJ 110.xx, MLton 20YY) and Standard Basis module.