Beginner

Variables and Types in Standard ML

Learn how Standard ML handles value bindings, type inference, composite types, and mutable references in a statically-typed functional language

Standard ML takes a different approach to “variables” than most imperative languages. In SML, what other languages call variables are more accurately called value bindings — names bound to values that cannot be reassigned. This immutability-by-default is a core feature of the language, not a restriction.

As a functional language, SML uses the val keyword to introduce new bindings. There is no reassignment of a binding once made; if you need a new value, you create a new binding. This design makes reasoning about code easier: a val binding always refers to the same value throughout its scope.

SML’s type system is one of its greatest contributions to programming language design. Through Hindley-Milner type inference, the compiler deduces the type of every expression without requiring explicit annotations. You get the safety of a static type system with most of the convenience of a dynamic one.

In this tutorial you will learn how SML handles primitive types, composite types like tuples and records, mutable state through reference cells, and the option type — SML’s safe alternative to null.

Primitive Value Bindings

The val keyword binds a name to a value. SML infers the type automatically from the value on the right-hand side.

Create a file named variables.sml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
(* Primitive type bindings in Standard ML *)

val age       = 42
val pi        = 3.14159
val name      = "Standard ML"
val isTypeSafe = true
val initial   = #"S"

val () = print ("Name:      " ^ name ^ "\n")
val () = print ("Age:       " ^ Int.toString age ^ "\n")
val () = print ("Pi:        " ^ Real.toString pi ^ "\n")
val () = print ("Type safe: " ^ Bool.toString isTypeSafe ^ "\n")
val () = print ("Initial:   " ^ String.str initial ^ "\n")

SML infers these types automatically:

  • ageint
  • pireal
  • namestring
  • isTypeSafebool
  • initialchar

The Basis Library provides conversion functions for each type: Int.toString, Real.toString, Bool.toString, and String.str (converts a single char to string). The ^ operator concatenates strings.

Note the #"S" syntax for character literals — SML uses a hash mark followed by a quoted single character, distinct from strings which use double quotes.

Type Annotations

Although type inference handles most cases, you can always add explicit type annotations. This is useful for documentation and for constraining polymorphic expressions.

Create a file named variables_annotated.sml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
(* Explicit type annotations *)
val count  : int    = 100
val ratio  : real   = 0.75
val label  : string = "elapsed"
val active : bool   = false

(* Type alias: give a name to a type *)
type milliseconds = int
type label_text   = string

val duration : milliseconds = 5000
val title    : label_text   = "load time"

(* Shadowing: a new binding with the same name *)
val count = count + 1        (* new binding; old count is still 100 *)

val () = print ("count (original shadowed): " ^ Int.toString count ^ "\n")
val () = print ("ratio:    " ^ Real.toString ratio ^ "\n")
val () = print ("duration: " ^ Int.toString duration ^ " ms\n")
val () = print ("title:    " ^ title ^ "\n")

Shadowing deserves attention. When you write val count = count + 1, you are not mutating the original count binding. You are creating a new binding that happens to share the name. The original binding is simply no longer reachable by that name. This is different from assignment in an imperative language.

Type aliases (type milliseconds = int) create readable names for types without introducing a new type — milliseconds and int are interchangeable to the compiler.

Composite Types: Tuples, Records, and Lists

SML has built-in composite types that group values together.

Create a file named variables_composite.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
26
27
28
29
30
31
(* Tuples: fixed-size, ordered, heterogeneous *)
val point   = (3, 4)            (* int * int *)
val triple  = (1, "hello", true) (* int * string * bool *)

(* Destructure a tuple with pattern binding *)
val (x, y) = point

(* Records: named fields *)
val person = {name = "Alice", age = 30, active = true}

(* Access record fields with # selector *)
val personName = #name person
val personAge  = #age  person

(* Lists: variable-length, homogeneous *)
val numbers = [1, 2, 3, 4, 5]      (* int list *)
val words   = ["sml", "is", "fun"]  (* string list *)
val empty   = []                    (* 'a list - polymorphic *)

(* List operations from the Basis Library *)
val count   = List.length numbers
val doubled = List.map (fn n => n * 2) numbers
val total   = List.foldl (fn (n, acc) => n + acc) 0 numbers

val () = print ("Point: (" ^ Int.toString x ^ ", " ^ Int.toString y ^ ")\n")
val () = print ("Person: " ^ personName ^ ", age " ^ Int.toString personAge ^ "\n")
val () = print ("List length: " ^ Int.toString count ^ "\n")
val () = print ("Sum: " ^ Int.toString total ^ "\n")
val () = print "Doubled: "
val () = List.app (fn n => print (Int.toString n ^ " ")) doubled
val () = print "\n"

Tuples are the simplest composite type: (3, 4) has type int * int. The * in a type reads as “and” — a pair of an int and an int. Destructuring with val (x, y) = point binds both components at once.

Records use named fields and are accessed with the #fieldname selector syntax. Unlike tuples, field order in a record doesn’t matter for type compatibility.

Lists in SML are singly-linked and homogeneous — all elements must have the same type. The Basis Library’s List structure provides length, map, foldl, filter, app, and many other functions.

Mutable State with Reference Cells

SML supports mutable state through reference cells — explicitly mutable locations in memory. Using references is a deliberate choice that makes mutability visible in the type system.

Create a file named variables_refs.sml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
(* Reference cells for mutable state *)

(* Create a ref with ref(...) or ref value *)
val counter = ref 0        (* int ref *)
val message = ref "hello"  (* string ref *)

(* Read a ref with the ! (dereference) operator *)
val () = print ("Initial counter: " ^ Int.toString (!counter) ^ "\n")
val () = print ("Initial message: " ^ !message ^ "\n")

(* Modify a ref with the := operator *)
val () = counter := !counter + 1
val () = counter := !counter + 1
val () = counter := !counter + 1
val () = message := "world"

val () = print ("Counter after 3 increments: " ^ Int.toString (!counter) ^ "\n")
val () = print ("Message after update: " ^ !message ^ "\n")

(* ref cells compose naturally *)
val flag = ref false
val () = flag := not (!flag)   (* flip the boolean *)
val () = print ("Flag flipped: " ^ Bool.toString (!flag) ^ "\n")

The type int ref means “a mutable cell holding an int.” Three operators work with refs:

  • ref value — creates a new reference cell containing value
  • !r — reads (dereferences) the current value of cell r
  • r := v — stores the value v into cell r

This design keeps mutability explicit. A function that takes an int cannot mutate it; only a function taking an int ref can do so, and that intent is visible in the type signature.

The Option Type

Standard ML has no null or nil pointer. Instead, the option type explicitly represents the possibility of a missing value. This eliminates an entire class of runtime errors.

Create a file named variables_option.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
26
27
28
29
30
31
32
(* The option type: SOME value or NONE *)

val found   : int option = SOME 42
val missing : int option = NONE

(* Safely extract the value with pattern matching *)
fun describeInt opt =
  case opt of
    NONE   => "no value"
  | SOME v => "value is " ^ Int.toString v

val () = print (describeInt found   ^ "\n")
val () = print (describeInt missing ^ "\n")

(* Option.getOpt provides a default value *)
val safeValue = Option.getOpt (found,   0)   (* 42 *)
val fallback  = Option.getOpt (missing, 0)   (* 0  *)

val () = print ("Safe value: " ^ Int.toString safeValue ^ "\n")
val () = print ("Fallback:   " ^ Int.toString fallback  ^ "\n")

(* Option.map applies a function only if SOME *)
val doubled = Option.map (fn x => x * 2) found    (* SOME 84 *)
val nothing = Option.map (fn x => x * 2) missing  (* NONE    *)

fun showOpt opt =
  case opt of
    NONE   => "NONE"
  | SOME v => "SOME " ^ Int.toString v

val () = print ("Doubled found:   " ^ showOpt doubled ^ "\n")
val () = print ("Doubled missing: " ^ showOpt nothing ^ "\n")

SOME and NONE are constructors of the 'a option type, which is defined as:

1
datatype 'a option = NONE | SOME of 'a

The 'a is a type variable meaning the option can hold any type. The compiler enforces that you handle both cases, so a missed NONE is a compile-time warning rather than a runtime crash.

Running with Docker

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Pull the SML/NJ image
docker pull eldesh/smlnj:latest

# Run the primitive types example
docker run --rm -v $(pwd):/app -w /app eldesh/smlnj:latest sml variables.sml

# Run the annotated types example
docker run --rm -v $(pwd):/app -w /app eldesh/smlnj:latest sml variables_annotated.sml

# Run the composite types example
docker run --rm -v $(pwd):/app -w /app eldesh/smlnj:latest sml variables_composite.sml

# Run the mutable references example
docker run --rm -v $(pwd):/app -w /app eldesh/smlnj:latest sml variables_refs.sml

# Run the option type example
docker run --rm -v $(pwd):/app -w /app eldesh/smlnj:latest sml variables_option.sml

Expected Output

Running variables.sml:

Name:      Standard ML
Age:       42
Pi:        3.14159
Type safe: true
Initial:   S

Running variables_annotated.sml:

count (original shadowed): 101
ratio:    0.75
duration: 5000 ms
title:    load time

Running variables_composite.sml:

Point: (3, 4)
Person: Alice, age 30
List length: 5
Sum: 15
Doubled: 2 4 6 8 10 

Running variables_refs.sml:

Initial counter: 0
Initial message: hello
Counter after 3 increments: 3
Message after update: world
Flag flipped: true

Running variables_option.sml:

value is 42
no value
Safe value: 42
Fallback:   0
Doubled found:   SOME 84
Doubled missing: NONE

Key Concepts

  • val bindings are immutable — once bound, a name always refers to the same value; SML has no assignment for val bindings
  • Type inference deduces every type automatically; annotations are optional and serve as documentation
  • Shadowing creates a new binding with an existing name rather than mutating the original
  • Character literals use #"c" syntax, distinct from single-element strings "c"
  • Tuples (int * string * bool) and records ({name: string, age: int}) group heterogeneous values; records add named access
  • Lists are homogeneous and singly-linked; the Basis Library’s List structure provides map, foldl, filter, and more
  • Reference cells (ref, !, :=) provide opt-in mutable state that is explicit in the type (int ref vs int)
  • The option type (SOME v / NONE) replaces null — the compiler requires you to handle both cases, preventing null-pointer errors at compile time

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