Data and Patterns
Python offers lists, tuples, dictionaries, dataclasses, enums, inheritance, and None. That flexibility is convenient, but it can leave basic questions to runtime: which fields exist, which alternatives are possible, and whether every case was handled.
Prism builds those answers into data types.
Lists, tuples, and comprehensions
A list contains values of one type. A tuple has a fixed number of positions whose types may differ:
fn main() =
let colours = ["red", "green", "blue"]
let reading = ("violet", 42)
let (name, value) = reading
println("{name}: {value}")
println(show(colours))
violet: 42
[red, green, blue]
The pattern (name, value) destructures the tuple. It does not index into an unknown object. The type establishes that the pair has exactly two positions.
List comprehensions look familiar, but their source is a stream. This one collects five squares into a list:
fn main() =
let squares = [n * n for n in srange(1, 6)]
println(show(squares))
[1, 4, 9, 16, 25]
Later we will keep a pipeline as a stream instead of collecting it. For now, use map or a comprehension when the result you want is another list.
Records are named products
A Python dataclass says that one value contains several named fields. A Prism record says the same thing without attaching methods or an inheritance tree:
type Colour = Colour { name: String, wavelength: Int }
fn describe(c : Colour) : String = "{c.name}: {c.wavelength}nm"
fn main() =
let violet = Colour { name = "violet", wavelength = 400 }
let shifted = Colour { ..violet, wavelength = 405 }
println(describe(violet))
println(describe(shifted))
violet: 400nm
violet: 405nm
Colour { ..violet, wavelength = 405 } creates an updated value. It does not mutate violet, which remains available with wavelength 400.
A record is a product because one Colour contains a name and a wavelength. A tuple is also a product. Records simply name the positions.
Algebraic data types enumerate alternatives
Suppose a reading is either visible, infrared, or invalid. In Python you might use an enum plus optional payload fields, several dataclasses behind a union, or a string tag and conventions. Prism declares the complete vocabulary directly:
type Reading
= Visible(String, Int)
| Infrared(Int)
| Invalid
fn describe(reading : Reading) : String =
match reading of
Visible(name, wavelength) => "{name} at {wavelength}nm"
Infrared(wavelength) => "infrared at {wavelength}nm"
Invalid => "invalid reading"
fn main() =
println(describe(Visible("red", 700)))
println(describe(Infrared(900)))
println(describe(Invalid))
red at 700nm
infrared at 900nm
invalid reading
Reading is a sum because a value has one shape or another. Each constructor determines its payload. Visible always carries a String and an Int, while Invalid carries nothing. An invalid mixture of fields cannot be constructed.
This is the first major functional-programming habit: design the valid shapes first, then let functions consume those shapes.
Patterns destructure and prove
A pattern performs two jobs at once. In Visible(name, wavelength), it proves that the value is the Visible alternative and gives names to its two fields. The names exist only in that arm.
More importantly, a match must cover every constructor:
type Reading
= Visible(String, Int)
| Infrared(Int)
| Invalid
fn describe(reading : Reading) : String =
match reading of
Visible(name, wavelength) => "{name} at {wavelength}nm"
Invalid => "invalid reading"
The missing Infrared arm is a compiler error. If you later add an Ultraviolet constructor, every incomplete match points to code whose policy must be reconsidered.
Patterns also work for literals, tuples, lists, and records. _ means “this shape is possible, but I do not need its value”:
fn first_or(xs : List(Int), fallback : Int) : Int =
match xs of
Nil => fallback
Cons(first, _rest) => first
fn main() =
println(first_or([], 9))
println(first_or([3, 4, 5], 9))
9
3
Option makes absence explicit
Python’s None can appear wherever an object was expected, whether or not the annotation admitted it. Prism uses the ordinary algebraic data type Option(a), whose alternatives are None and Some(a):
fn visible_name(reading : Reading) : Option(String) =
match reading of
Visible(name, _wavelength) => Some(name)
Infrared(_wavelength) => None
Invalid => None
fn name_or_unknown(name : Option(String)) : String =
match name of
Some(value) => value
None => "unknown"
fn main() =
println(name_or_unknown(visible_name(Visible("green", 550))))
println(name_or_unknown(visible_name(Infrared(900))))
green
unknown
Option(String) announces absence to every caller. Accessing the string requires handling Some and None. There is no stray null reference to fail somewhere unrelated.
Try it: Add
Ultraviolet(Int)toReading. Let the compiler show every match that became incomplete, then decide separately whatdescribeandvisible_nameshould do with it.
Checkpoint
You are ready to continue when you can explain:
- a record is one shape containing several fields.
- an algebraic data type is a closed set of possible shapes.
- a constructor builds one shape and a pattern takes it apart.
- exhaustiveness turns a data-model change into a useful list of affected code.
Next, Purity and Effect Types moves from the shapes of values to the observable actions computations may perform.
Further reading: algebraic data types, records, patterns, and comprehensions.