Beginner

Control Flow in Standard ML

Learn control flow in Standard ML - if expressions, case pattern matching, recursion, and while loops with practical Docker-ready examples

Control flow determines the order in which a program’s logic executes. In most imperative languages this means statements that branch and loops that iterate. Standard ML approaches the problem differently: as a functional language, its control-flow constructs are expressions that produce values, and the workhorse for repetition is recursion, not the for loop.

This shift matters. An if in SML is not a statement that conditionally runs code — it is an expression that evaluates to one of two values, so both branches must have the same type. Likewise, case is far more than a switch: it is built on pattern matching, the same mechanism that destructures data throughout the language. Where Java or Python reach for a counter and a loop body, idiomatic SML defines a recursive function whose patterns describe each case of the data.

SML is multi-paradigm, so it does provide imperative while loops and mutable references for the rare cases that genuinely need them. But you will write far less of that than you might expect. This tutorial walks through both the functional core — if, case, recursion, guards — and the imperative escape hatches, so you can recognize idiomatic SML and know when each tool fits.

Conditional Expressions: if/then/else

In SML, if ... then ... else is an expression, not a statement. It always returns a value, and the else branch is mandatory because every expression must produce a result. Both branches must have the same type.

Create a file named conditionals.sml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
(* if/then/else is an expression that evaluates to a value *)
fun classify n =
  if n > 0 then "positive"
  else if n < 0 then "negative"
  else "zero"

val () = print (classify 5 ^ "\n")
val () = print (classify ~3 ^ "\n")   (* ~ is SML's negation, not - *)
val () = print (classify 0 ^ "\n")

(* Because if is an expression, it can be assigned directly *)
val larger = if 10 > 7 then 10 else 7
val () = print ("larger = " ^ Int.toString larger ^ "\n")

(* andalso / orelse are short-circuit boolean operators *)
fun inRange x = x >= 1 andalso x <= 100
val () = print ("50 in range? " ^ Bool.toString (inRange 50) ^ "\n")
val () = print ("200 in range? " ^ Bool.toString (inRange 200) ^ "\n")

There is no ternary operator in SML — and none is needed, since if already is the conditional expression. Note that SML writes negative numbers with a tilde (~3), reserving - for subtraction. The andalso and orelse keywords are the short-circuit logical AND and OR.

Pattern Matching with case

The case expression compares a value against a series of patterns and evaluates the branch for the first one that matches. It is the functional replacement for a switch statement, but considerably more powerful — patterns can destructure tuples, lists, and custom datatypes. The wildcard _ matches anything.

Create a file named case_match.sml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
(* case matches a value against patterns, top to bottom *)
fun describe n =
  case n of
    0 => "zero"
  | 1 => "one"
  | 2 => "two"
  | _ => "many"      (* _ is the catch-all wildcard *)

val () = print (describe 0 ^ "\n")
val () = print (describe 2 ^ "\n")
val () = print (describe 99 ^ "\n")

(* Pattern matching shines on custom datatypes *)
datatype day = Mon | Tue | Wed | Thu | Fri | Sat | Sun

fun isWeekend d =
  case d of
    Sat => true
  | Sun => true
  | _   => false

val () = print ("Sat weekend? " ^ Bool.toString (isWeekend Sat) ^ "\n")
val () = print ("Mon weekend? " ^ Bool.toString (isWeekend Mon) ^ "\n")

A major advantage: the compiler checks that your patterns are exhaustive. If you forget a case, SML warns you at compile time — a safety net that prevents an entire class of bugs. Note that => (the match arrow) is distinct from -> (the function-type arrow).

Recursion Replaces Loops

This is where SML diverges most from imperative languages. To repeat work, you define a recursive function whose patterns describe each case. The two clauses below — one for the base case, one for the recursive step — are the loop. Clauses are separated by |, mirroring the case syntax.

Create a file named recursion.sml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
(* Two clauses: a base case and a recursive case *)
fun countdown 0 = print "Liftoff!\n"
  | countdown n = (
      print (Int.toString n ^ "... ");
      countdown (n - 1)
    )

val () = countdown 5

(* Recurse over a list's structure: [] vs head::tail *)
fun sumList [] = 0
  | sumList (x::xs) = x + sumList xs

val () = print ("Sum = " ^ Int.toString (sumList [1, 2, 3, 4, 5]) ^ "\n")

(* Tail recursion with an accumulator - the idiomatic "loop" *)
fun factorial n =
  let
    fun loop (0, acc) = acc
      | loop (k, acc) = loop (k - 1, k * acc)
  in
    loop (n, 1)
  end

val () = print ("5! = " ^ Int.toString (factorial 5) ^ "\n")

The sumList function shows the classic list pattern: [] matches the empty list, and x::xs matches a non-empty list, binding x to the head and xs to the tail. The accumulator-based factorial is tail recursive — the recursive call is the last thing the function does — which lets SML compile it into an efficient loop with constant stack usage.

Imperative Loops with while

SML is multi-paradigm, so when an algorithm is genuinely iterative and stateful, you can use a while loop together with mutable references (ref). Use ! to read a reference and := to update it. Reach for this only when recursion would be awkward — most SML code never needs it.

Create a file named loops.sml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
(* A while loop driven by mutable references *)
val i = ref 1
val sum = ref 0

val () =
  while !i <= 10 do (
    sum := !sum + !i;   (* := updates a ref *)
    i := !i + 1         (* ! reads a ref *)
  )

val () = print ("Sum 1..10 = " ^ Int.toString (!sum) ^ "\n")

(* Functional iteration over a list - no explicit loop needed *)
val () = List.app (fn x => print (Int.toString x ^ " ")) [10, 20, 30]
val () = print "\n"

There is no for loop in Standard ML. For walking a collection, the functional List.app (apply a function to each element for its side effect) is clearer than manual indexing, and higher-order functions like map and foldl cover most of what loops are used for elsewhere.

Putting It Together: FizzBuzz

This classic exercise combines conditionals and recursion. The inner line function uses chained if expressions to decide each number’s label, and loop recurses from 1 to n — the functional equivalent of a counting loop.

Create a file named control_flow.sml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
fun fizzbuzz n =
  let
    fun line k =
      if k mod 15 = 0 then "FizzBuzz"
      else if k mod 3 = 0 then "Fizz"
      else if k mod 5 = 0 then "Buzz"
      else Int.toString k
    fun loop k =
      if k > n then ()                          (* base case: stop *)
      else (print (line k ^ "\n"); loop (k + 1))
  in
    loop 1
  end

val () = fizzbuzz 15

The base case returns () (the unit value) to stop the recursion, and the recursive case prints a line then advances the counter. Sequencing two expressions with ; inside parentheses runs them in order — print first, then the recursive call.

Running with Docker

1
2
3
4
5
6
7
8
9
# Pull the SML/NJ image
docker pull eldesh/smlnj:latest

# Run each example
docker run --rm -v $(pwd):/app -w /app eldesh/smlnj:latest sml conditionals.sml
docker run --rm -v $(pwd):/app -w /app eldesh/smlnj:latest sml case_match.sml
docker run --rm -v $(pwd):/app -w /app eldesh/smlnj:latest sml recursion.sml
docker run --rm -v $(pwd):/app -w /app eldesh/smlnj:latest sml loops.sml
docker run --rm -v $(pwd):/app -w /app eldesh/smlnj:latest sml control_flow.sml

SML/NJ prints a version banner and val it = () : unit binding echoes around your program’s output; the outputs below show just the text your programs produce.

Expected Output

conditionals.sml:

positive
negative
zero
larger = 10
50 in range? true
200 in range? false

case_match.sml:

zero
two
many
Sat weekend? true
Mon weekend? false

recursion.sml:

5... 4... 3... 2... 1... Liftoff!
Sum = 15
5! = 120

loops.sml:

Sum 1..10 = 55
10 20 30 

control_flow.sml:

1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz

Key Concepts

  • if is an expression, not a statement — it always returns a value, the else branch is mandatory, and both branches must share a type. There is no ternary operator because if already fills that role.
  • case is built on pattern matching — it destructures values and the compiler warns when your patterns aren’t exhaustive, catching missing cases before runtime.
  • Recursion replaces loops — repetition is expressed with a recursive function’s base and recursive clauses, separated by |. List recursion uses the [] and x::xs patterns.
  • Tail recursion is efficient — when the recursive call is the last action, SML runs it in constant stack space, just like an imperative loop.
  • andalso and orelse are the short-circuit boolean operators; = is equality (there is no ==).
  • Imperative loops exist but are rarewhile ... do with ref, !, and := is available, and there is no for loop; prefer List.app, map, and foldl for collections.
  • Mind the arrows and the tilde=> separates match patterns from results, -> denotes function types, and negative numbers are written with ~ (e.g., ~3).

Running Today

All examples can be run using Docker:

docker pull eldesh/smlnj:latest
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining