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:
| |
SML infers these types automatically:
age→intpi→realname→stringisTypeSafe→boolinitial→char
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:
| |
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:
| |
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:
| |
The type int ref means “a mutable cell holding an int.” Three operators work with refs:
ref value— creates a new reference cell containingvalue!r— reads (dereferences) the current value of cellrr := v— stores the valuevinto cellr
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:
| |
SOME and NONE are constructors of the 'a option type, which is defined as:
| |
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
| |
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
valbindings are immutable — once bound, a name always refers to the same value; SML has no assignment forvalbindings- 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
Liststructure providesmap,foldl,filter, and more - Reference cells (
ref,!,:=) provide opt-in mutable state that is explicit in the type (int refvsint) - The
optiontype (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
Comments
Loading comments...
Leave a Comment