Blog › ICP guides
Standard ML developer on retainer: exhaustive pattern matching, wildcard silent match, value restriction, and SML on monthly retainer
October 3, 2026 · ~14 min read
A Standard ML developer was maintaining a type-safe configuration parser for an embedded domain-specific language in SML/NJ. The parser consumed a configuration file and produced a structured configuration value using a datatype called config_value, which initially had four constructors: StringVal of string, IntVal of int, BoolVal of bool, and ListVal of config_value list. The parser, validator, serializer, and logging functions all pattern-matched on config_value using case expressions. Most of these match expressions had been written as three explicit cases for the known constructors plus a wildcard: | _ => default_value. The system had been working correctly for two years.
The developer added network timeout configuration to the system: a new constructor NetworkTimeout of int was added to config_value to represent a timeout duration in milliseconds, parsed from a new network_timeout key in the configuration file. The developer updated the parser to produce NetworkTimeout n values and wrote the handler for the new constructor in the top-level configuration processing function, which correctly handled the new case. Compilation succeeded. The system loaded and ran without error.
What actually happened: the four match expressions that used wildcards — parse_config, validate_config, serialize_config, and log_config — all silently routed the new NetworkTimeout n constructor to their | _ => default_value branch. Because the wildcard makes the match exhaustive, SML/NJ produced no exhaustiveness warning when the NetworkTimeout constructor was added. The developer was not alerted that four match expressions on config_value needed to be updated. All five configuration values of type NetworkTimeout that the parser produced were silently treated as default_value (which mapped to a zero-millisecond timeout in the context of parse_config), causing every network operation in the system to time out immediately. Dropped config values: 5 → 0 after replacing the wildcard in each match expression with an explicit NetworkTimeout case, and removing the wildcard from the three match expressions where it provided no semantic value beyond exhaustiveness coverage.
The reason this class of bug is systematically invisible in Standard ML is that the wildcard | _ => case has two very different intended semantics that look identical in source code. The first is “handle all remaining constructors the same way, including constructors that may be added in the future” — a deliberate design choice for functions that treat most constructors uniformly. The second is “this match should be exhaustive right now; I’m using a wildcard so the compiler won’t warn me about the current constructors I haven’t listed explicitly” — effectively using the wildcard to suppress the exhaustiveness warning rather than to express a semantic intent. In the second case, when a new constructor is added, the developer wants to be warned to update the match, but the wildcard they wrote to suppress the warning earlier continues to suppress the warning for the new constructor too.
Standard ML: the 1997 Definition, Hindley-Milner type inference, and the ML family
Standard ML is defined by “The Definition of Standard ML (Revised)” (Milner, Tofte, Harper, MacQueen, 1997), which specifies the language using an operational semantics (the formal rules describing how SML programs evaluate) and an elaboration relation (the rules describing how SML programs are type-checked). The 1997 Definition superseded the original 1990 Definition and is the authoritative specification for all compliant SML implementations. The ML language family originated with Robin Milner’s Meta Language (ML), developed at the University of Edinburgh in the 1970s as the metalanguage of the LCF theorem prover: ML was the language in which LCF proof tactics were written, and its type system was designed to guarantee that LCF proof terms were always well-typed. The insight that the type checker could infer types automatically — without requiring the programmer to annotate every expression — was formalized by Milner as the Hindley-Milner type inference algorithm (independently discovered by J. Roger Hindley in 1969 and Milner in 1978, building on work by Curry and Howard).
Hindley-Milner type inference operates by assigning type variables to expressions and then solving the constraints imposed by how those expressions are used. A function fun id x = x is assigned type 'a -> 'a: the type variable 'a expresses that the function works for any type, and all uses of the function are unified with this polymorphic type. The elaboration algorithm — called Algorithm W in its published form — processes each expression, assigns a type (possibly containing fresh type variables), and unifies the type with the constraints from the expression’s context. If unification fails (because two incompatible types are being unified), elaboration produces a type error. The practical result: almost all type annotations are optional in SML; the compiler infers them from usage. Explicit type annotations are used for documentation, to guide error messages, or to restrict a value to a less general type.
Algebraic data types (datatypes in SML) are the primary structuring mechanism for data. A datatype declaration introduces a type and its constructors: datatype config_value = StringVal of string | IntVal of int | BoolVal of bool | ListVal of config_value list. Each constructor is a function from its argument type to config_value (or, for nullary constructors, a value of type config_value directly). Pattern matching on a datatype is exhaustive: the compiler verifies that every constructor is handled. When a wildcard is present, the match is trivially exhaustive — the wildcard matches everything. SML’s exhaustiveness check is the key safety guarantee that makes datatype evolution safe in the absence of wildcards: adding a new constructor and recompiling will produce exhaustiveness warnings for every match expression on that datatype that does not handle the new constructor, guiding the developer to the exact functions that need updating.
SML/NJ (Standard ML of New Jersey), the implementation of SML developed at Bell Labs and Princeton University, is the most widely used SML implementation for interactive development and systems programming. It provides a REPL (the SML/NJ interactive top level), a build system (ml-build / CM), continuations as first-class values (SMLofNJ.Cont.callcc), and a Concurrent ML extension (CML) for message-passing concurrency. MLton (whole-program optimizing compiler) compiles SML to highly optimized native code without an interactive REPL, producing executables that outperform SML/NJ in runtime at the cost of longer compilation times. The 1997 Definition is the single shared specification; both SML/NJ and MLton comply, and programs that avoid implementation-specific features are portable between them.
Value restriction, module system, and SML exceptions
The value restriction is Standard ML’s solution to a soundness problem in the interaction between Hindley-Milner polymorphism and mutable references. The problem: without restriction, a polymorphic reference could be used to store a value of one type and retrieve it as a different type, breaking type safety. The 1997 Definition addresses this by restricting which expressions can be generalized to polymorphic types: only “syntactic values” (variables, function abstractions fn x => e, constructor applications to values, record and tuple expressions of values) can be given polymorphic types. An expression that is not a syntactic value but has a polymorphic type is restricted to a monomorphic type by SML/NJ, meaning its type variable becomes a fixed (but unknown) type that cannot be instantiated at multiple types. A retainer bug: a developer writes val sorted = ListMergeSort.sort (fn (a, b) => ...) [] expecting sorted to have type 'a list (polymorphic); but the expression is not a syntactic value (it’s a function application), so SML/NJ assigns it a monomorphic type; using sorted at two different list element types later in the code produces a type error. Fix: eta-expand to fun sorted lst = ListMergeSort.sort (fn (a, b) => ...) lst, which is a function abstraction and therefore a syntactic value, eligible for generalization.
The SML module system consists of three components: structures (implementations), signatures (interfaces), and functors (parameterized structures). A structure is a collection of type definitions, value definitions, and nested structures. A signature specifies the types of all the components a structure must provide, without revealing their implementation. A functor is a function from structures to structures: it takes a structure (or multiple structures) satisfying a given signature and produces a new structure. Signature ascription comes in two forms: transparent ascription (: SIG) reveals the implementation of abstract types in the signature, allowing external code to see that the type is concrete; opaque ascription (:> SIG) seals the type, making it abstract to external code even if the signature lists it as a concrete type synonym. A retainer bug in signature ascription: a developer uses opaque ascription to seal a structure’s type t, making it abstract; another module that was sharing the concrete type t = int through a sharing constraint now receives a type mismatch error because the sealed type t is no longer equal to int externally; the fix is changing to transparent ascription or adding a sharing type constraint to the functor parameter signature.
SML exceptions are a first-class mechanism for non-local control flow. An exception is declared with exception Name of type and raised with raise Name value; it is caught with a handle expression: expr handle Name x => handler | Other y => handler2. Unlike Java’s checked exceptions, SML exceptions are not reflected in function types: a function that raises an exception has the same type as one that does not, which means exception raising is invisible in the type system and cannot be verified by the compiler. A retainer bug in exception handling: a developer adds a new exception constructor to a module’s exception type (e.g., adds exception NetworkTimeout to a module that previously only raised exception ConnectionRefused and exception Timeout); calling code’s handle expression does not include a case for NetworkTimeout; the new exception propagates uncaught to the top level, terminating the process. Unlike datatype pattern matching, SML does not produce exhaustiveness warnings for exception handle expressions, because exceptions are open (new exceptions can be declared in any module at any time, making exhaustive handling impossible in general). Work log entry: “connect function: raised new NetworkTimeout exception for 500ms timeout case; 3 callers handled Timeout but not NetworkTimeout; processes terminated with unhandled exception for 5 timeout events; fix: added | NetworkTimeout => handle_timeout () to 3 call sites; unhandled exceptions: 5 → 0; 2h.”
Typical Standard ML retainer work and what it looks like in a work log
Wildcard-suppressed exhaustiveness bugs are the largest category of Standard ML retainer work that produces no visible artifact. The pattern follows the same shape: a datatype is extended with a new constructor; match expressions on the datatype that use wildcards do not produce warnings; the new constructor’s values are silently routed to the wildcard handler; the developer who added the constructor must manually audit every match expression on the datatype to find the ones that need updating. SML/NJ does not provide a “find all match expressions on this datatype” tool; the audit requires grepping the codebase for the datatype name, identifying match expressions, and reading each match expression to determine whether its wildcard was intentional or accidental. Work log entry: “config_value datatype: new NetworkTimeout constructor; 4 match expressions with | _ => wildcards: parse_config (wildcard maps to zero timeout — wrong), validate_config (wildcard skips validation — wrong), serialize_config (wildcard emits default — wrong), log_config (wildcard logs as unknown — possibly acceptable); replaced wildcard with NetworkTimeout case in parse_config, validate_config, serialize_config; kept wildcard in log_config with comment; 5 dropped config values: 5 → 0; 2.5h.”
Functor application type errors are the second category. A functor specifies a formal parameter signature that the argument structure must satisfy. The satisfaction check verifies that every type, value, and nested structure in the parameter signature has a corresponding component in the argument structure with a compatible type. A common failure: the functor’s parameter signature requires a polymorphic comparison function val compare : 'a * 'a -> order, but the argument structure provides val compare : string * string -> order (a monomorphic version). The argument structure does not satisfy the signature, and the functor application fails with a type error at the argument structure’s compare component. Fix: make the argument structure’s compare polymorphic, or define a new signature for the functor that matches the monomorphic comparison. Work log entry: “BSearchTree functor: parameter signature requires val compare : 'a * 'a -> order; StringOrd structure provides val compare : string * string -> order; functor application BSearchTree(StringOrd) fails with type mismatch on compare; fix: defined StringSortable signature with val compare : string * string -> order; defined StringBSearchTree functor with monomorphic parameter; type errors: 1 → 0; 2h.”
Mutable reference escape bugs are the third category. SML provides mutable state through references: val r = ref 0 creates a reference cell holding 0; !r dereferences it; r := 5 updates it. References are first-class values that can be passed to functions, stored in data structures, and returned from functions. A retainer bug: a function returns a reference that the function also holds internally; the caller modifies the reference expecting a local effect; the function reads the reference at a later call and sees the caller’s modification; the two parties share state that was not intended to be shared. This is the reference aliasing problem. Work log entry: “make_counter: returns ref 0 directly, not a copy; caller increments returned ref expecting local count; internal counter and caller’s count alias; reset call from unrelated code path zeros both; 3 wrong count reads in 1000-step simulation; fix: returned ref is a copy (ref (!internal_ref)); internal ref and caller’s ref are now distinct; wrong reads: 3 → 0; 2h.”
Track Standard ML developer retainer hours without the status emails
When a 2.5-hour session diagnoses a wildcard-suppressed exhaustiveness bug in the config_value datatype — grepping the codebase for all match expressions on the datatype, identifying four match expressions with accidental wildcards, adding explicit NetworkTimeout cases to three of them, and verifying that all five dropped config values now reach their intended handler — the work log must name the datatype, the new constructor, the four match expressions, and the dropped-value count before and after. HourTab gives your Standard ML retainer client a public dashboard URL they can bookmark: hours used, hours remaining, and a work log that names the datatype and the pattern match audit. No client login. No status emails. CSV in, URL out.
How HourTab tracks Standard ML developer retainer hours
Standard ML retainer work is invisible by the same mechanism that makes wildcard exhaustiveness bugs dangerous: SML’s exhaustiveness check — the feature specifically designed to alert developers when a match needs updating after a datatype change — is silenced by the wildcard that the developer wrote to avoid earlier warnings. The compiler sees a complete, well-typed, exhaustive match. No warning is issued. The code compiles and runs; the new constructor’s values silently reach the wildcard handler, which produces an incorrect output that looks like a valid result. The symptom — network operations timing out immediately — could be caused by an actual network problem, a configuration file format error, or a misread timeout value, all of which are investigated before the match expression is examined.
The work log needs to name the mechanism: which datatype, which new constructor, which match expressions had wildcards, which wildcards were accidental (and have been replaced with explicit cases) versus intentional (and have been left with comments), and what the dropped-value count was before and after the fix. A log entry that says “fixed config parser bug, 2.5h” is not auditable. A log entry that says “config_value: new NetworkTimeout constructor silently swallowed by | _ => default_value wildcard in 4 match expressions; parse_config returned zero timeout (5 values dropped), validate_config skipped timeout validation (5 values unvalidated), serialize_config emitted default (5 values wrong in output); fix: replaced wildcard with explicit NetworkTimeout n => ... case in 3 functions; 5 → 0 dropped values; 2.5h” is auditable.
HourTab gives Standard ML developers a public retainer-hours URL they send to clients — formal verification research groups using SML for theorem prover implementations, compiler research groups using SML to implement ML-family compilers, financial technology firms running SML-based trading system components, and academic CS departments maintaining SML-based course infrastructure. For Standard ML retainers, each work log entry should name the mechanism: which datatype or function, which constructor or type variable, what the exhaustiveness gap or value restriction failure was, and what the dropped-value or type error count was before and after. Comparative context: Standard ML retainer work has structural overlap with other statically typed functional languages — OCaml retainers cover exhaustive pattern matching in OCaml’s type system, where OCaml’s -Wincomplete-patterns warning is the direct analogue of SML’s exhaustiveness check, and wildcard suppression produces the same invisible new-constructor-not-handled bug; Haskell retainers involve -Wincomplete-patterns and -Wincomplete-uni-patterns GHC warnings for the same class of non-exhaustive match bug, with wildcards in case expressions producing the same suppression effect; and Scheme retainers cover cond/case expression completeness in a dynamically typed language where there is no compile-time exhaustiveness check, making the wildcard problem invisible in an entirely different way: any cond branch can fall through to else without warning regardless of how many cases have been added since the expression was written.
FAQ: Standard ML developer retainers
What does a Standard ML developer on retainer typically do?
A Standard ML developer on monthly retainer covers wildcard pattern match diagnosis (identifying match expressions where | _ => suppresses the exhaustiveness warning for new datatype constructors; the fix is replacing the wildcard with explicit constructor cases in functions where the new constructor requires distinct handling); value restriction diagnosis (identifying non-value polymorphic expressions that SML/NJ assigns monomorphic types, causing use-at-multiple-types errors; the fix is eta-expansion or restructuring); functor application type error diagnosis (identifying where a structure does not satisfy a functor’s parameter signature due to type generality mismatches; the fix is adjusting the signature or the structure); opaque vs transparent signature ascription debugging (identifying where :> seals a type that other modules need concrete; the fix is switching to : or adding sharing constraints); and exception handling audit (identifying where new exception constructors are raised but not caught at call sites, causing unhandled exception termination).
What Standard ML work is most commonly underlogged?
Wildcard-suppressed pattern exhaustiveness bugs are the most systematically underlogged Standard ML retainer work. SML’s exhaustiveness check — the key safety guarantee for datatype evolution — is silenced by a | _ => wildcard in any match expression. When a new constructor is added to a datatype, all match expressions with wildcards silently route the new constructor to the wildcard handler without any compiler warning. The developer must manually audit every match expression on the datatype to find the ones that need updating. There is no SML/NJ tool to locate these; the audit requires reading every case expression on the datatype in every module. Two to four hours invisible per constructor addition.
What are typical Standard ML developer retainer rates?
Entry-level Standard ML developers with experience in SML/NJ, basic datatype pattern matching, and the Basis Library typically bill at $65 to $115 per hour. Mid-level Standard ML programmers with experience in the module system (structures, signatures, functors), value restriction, and MLton optimization typically bill at $95 to $170 per hour. Senior Standard ML developers with deep knowledge of the 1997 Definition, SML/NJ continuations, MLton whole-program compilation, and large-scale SML system design typically bill at $140 to $255 per hour. Monthly retainer ranges: $1,400 to $2,600 per month for advisory engagements covering datatype evolution audits and module system reviews (10 to 18 hours per month); $2,000 to $5,000 per month for active maintenance including signature restructuring, value restriction fixes, and MLton compilation tuning.
What should a Standard ML developer retainer agreement include?
A Standard ML developer retainer agreement should specify: implementation scope (SML/NJ vs MLton; SML/NJ for interactive development and continuation support; MLton for optimized native code; implementation differences matter for runtime behavior and compilation workflow); pattern match scope (whether the retainer covers auditing wildcard cases in match expressions on evolving datatypes); value restriction scope (whether eta-expansion and refactoring of non-value polymorphic expressions is in scope); module system scope (signature ascription choices, functor application type errors, sharing constraint design); Basis Library scope (CharVector, Real, IntInf, TextIO module idiosyncrasies); exception handling scope (whether auditing call sites for uncaught new exception constructors is in scope); and MLton scope (MLton-specific annotations for foreign function interface or performance work).
How should Standard ML developer retainer hours be logged?
Log each Standard ML retainer session with: the datatype name and the new constructor (e.g., config_value datatype; new NetworkTimeout of int constructor); the match expressions where wildcards silently swallowed the new constructor (e.g., parse_config, validate_config, serialize_config, log_config); the semantic impact of each wildcard handler (e.g., parse_config: wildcard returned zero timeout; validate_config: wildcard skipped validation); the symptom with count (e.g., 5 network timeout config values dropped/treated as default); and the fix (e.g., replaced wildcard with NetworkTimeout case in 3 of 4 expressions; kept wildcard in log_config with comment; dropped values: 5 → 0; 2.5h). For value restriction: the expression, the SML/NJ error, and the eta-expansion applied. For functor errors: the functor name, the signature mismatch, and the structural fix.