Sum Types and Match
Modeling a Newton-style solver’s outcomes with an enum, then dispatching with match.
A solver loop typically has three distinct outcomes: it converged on a value, the iterates diverged, or it ran out of iterations without converging. Modeling the result as a flat structure ((success: bool, x: real, iters: integer)) loses information: the meaning of x depends on success, and the meaning of iters depends on both. A sum type makes the three cases — and the data each one carries — explicit.
The driver below is Newton’s method for \(f(x) = 0\),
reporting Converged(x) once \(\lvert f(x_n) \rvert < \text{tol}\), Diverged if \(f'(x_n)\) approaches zero (the update step blows up), and MaxIters(iters, last) if the loop exhausts its budget without either condition firing. The example function is \(f(x) = x^2 - 2\), \(f'(x) = 2x\), so a converging run lands on \(\sqrt{2}\).
The solver
enum Solver =
Converged(x: real)
Diverged
MaxIters(iters: integer, last: real)
def newton(f: real -> real, fp: real -> real, x0: real, tol: real, max_iters: integer): Solver =
var x = x0
for i in 0..max_iters do
val fx = f(x)
if abs(fx) < tol then return Converged(x)
val d = fp(x)
if abs(d) < 1.0e-15 then return Diverged
x = x - fx / d
end for
MaxIters(max_iters, x)
def f(x: real) : real = x^2 - 2.0
def fp(x: real): real = 2.0 * x
Each return site builds a value of the enum type. Converged(x) calls the fielded constructor (one real field); Diverged is a bare variant, so the name itself is the value; MaxIters(...) constructs the two-field variant. All three are typed Solver, so the function’s declared return type is satisfied uniformly.
Inspecting the outcome with match
def describe(s: Solver): string =
s match
Converged(x) -> s"converged at x=${x}"
Diverged -> "diverged"
MaxIters(iters, x) -> s"ran ${iters} iters, last x=${x}"
def main() =
print(describe(newton(f, fp, 1.0, 1.0e-12, 50)))
print(describe(newton(f, fp, 0.0, 1.0e-12, 50))) // fp(0)=0 → Diverged
print(describe(newton(f, fp, 1.0e9, 1.0e-12, 3))) // 3 iters too few
Output:
converged at x=1.4142135623730951
diverged
ran 3 iters, last x=125000000.0
Three properties to notice:
- Exhaustiveness. Every variant of
Solverhas its own arm. Drop one and the compiler refuses to build the program — there is no quiet “default fell through” path. A wildcardcase _ =>arm is allowed when only some variants matter, but you opt in by writing it. - Field binding.
Converged(x)extracts therealfield into a name visible inside the arm body.MaxIters(iters, x)binds two fields in declaration order, available throughout the arm body. Use_for any field you don’t need (case Continue(_) => ...). - One result type. Every arm body must evaluate to the same type — here,
string. The wholematchexpression is itself astring, so it can flow intoprint, into a binding, or into another expression.
When to reach for a sum type
A sum type fits when a value has a bounded set of named cases, each with its own associated data:
- Solver outcomes (
Converged | Diverged | MaxIters) - Parsed tokens (
Number(real) | Identifier(string) | LParen | RParen | ...) - Tree shapes (
Leaf(real) | Branch(real, real)— once nested variant fields ship) - Operation results that distinguish “succeeded with x” from “failed because of y”
If the cases all carry the same shape and only differ in a small tag, a struct with an integer/string tag field is simpler. If the cases are unbounded or grow at runtime (user-defined plugins, dynamic types), a sum type isn’t the right tool — that’s where traits / interfaces would fit, both of which are deferred from the current language.