Lesson 15 of 25 · Data and types

Enums

An enum is a type with a closed set of cases: a traffic light is red, green or yellow and nothing else. Because the compiler knows every case, it can tell you when your code forgets one. A case can also carry data of its own, which makes enums the natural way to say "one of these kinds, with these details".

A plain enum

Cases are separated by commas and numbered from zero. You can give a case a value of your own, and the cases after it carry on from there. as int gives you the number behind a case.

enum-values.salam
enum Status:
    Draft, Published = 10, Archived
end

func main:
    state := Status.Published
    println state as int, Status.Archived as int
    println state == Status.Published
end
Output10 11 true

.name() gives the name of a case as a string, and .Count() on the enum itself gives the number of cases:

enum-name.salam
enum Season: Spring, Summer, Autumn, Winter end

func main:
    s := Season.Autumn
    println s.name()
    println "number of seasons:", Season.Count()
    println s == Season.Autumn, s as int
end
OutputAutumn number of seasons: 4 true 2

Enums and match

match on an enum lists the cases by their bare names. It must cover every case or end with else; leave one out and the program does not compile: E080: non-exhaustive match on enum 'Light': missing member Yellow. That check is the main reason to use an enum instead of plain numbers or strings. When you add a case later, the compiler points at every match that has to learn about it.

enum-state.salam
enum Light: Red, Green, Yellow end

func next(l: Light): Light:
    ret match l:
        Red: Light.Green end
        Green: Light.Yellow end
        Yellow: Light.Red end
    end
end

func seconds(l: Light): int:
    ret match l:
        Red: 30 end
        Green: 25 end
        Yellow: 5 end
    end
end

func main:
    mut light := Light.Red
    mut cycle := 0
    repeat Light.Count():
        println light.name(), seconds(light)
        cycle += seconds(light)
        light = next(light)
    end
    println "full cycle:", cycle, "seconds, back to", light.name()
end
OutputRed 30 Green 25 Yellow 5 full cycle: 60 seconds, back to Red

Each function describes one fact about every state, and Light.Count() makes the loop go round exactly once, however many cases there are. A small state machine like this, where each state knows the next one, turns up everywhere: order processing, game turns, connection status.

Write else only when the remaining cases really share one answer. A catch-all else also swallows the cases you add later, and the compiler can no longer remind you about them.

Enums in structs and stored values

An enum is a good type for a struct field with a fixed set of states. Printing the struct shows the field as its number, so use name() when a person will read it.

enum-order.salam
enum Status: Placed, Packed, Shipped, Delivered end

struct Order:
    pub id: int = 0
    pub status: Status = Status.Placed
end

func status_from(code: int): Status:
    if code < 0 or code >= Status.Count():
        ret Status.Placed
    end
    ret code as Status
end

func main:
    mut order := Order { id = 1042 }
    order.status = Status.Packed
    println order
    println order.id, order.status.name()

    saved := order.status as int
    println "saved as", saved, "read back as", status_from(saved).name()
    println "unchecked:", "[" + (9 as Status).name() + "]", "checked:", status_from(9).name()
end
OutputOrder {id = 1042, status = 1} 1042 Packed saved as 1 read back as Packed unchecked: [] checked: Placed

The number is what you store in a file or a database, and code as Status turns it back into a case. That conversion is not checked: 9 as Status is accepted even though no case has the value 9, and its name() is an empty string. When the number comes from outside the program, check it first, as status_from does with Status.Count(). When the cases have values of their own, such as Published = 10, compare against those instead.

Enums that carry data

A case can carry named data, such as a radius for a circle. Build one with Shape.Circle(2.0), and use match to tell the cases apart and pull the data out. A branch may add a condition with if. A guarded branch does not count as covering its case, so keep an unguarded branch for the same case after it.

enum-data.salam
enum Shape:
    Circle(radius: f64)
    Rectangle(width: f64, height: f64)
    None
end

func area(s: Shape): f64:
    ret match s:
        Circle(r): 3.14 * r * r end
        Rectangle(w, h) if w == h: w * w end
        Rectangle(w, h): w * h end
        None: 0.0 end
    end
end

func main:
    println area(Shape.Circle(2.0))
    println area(Shape.Rectangle(3.0, 4.0))
    println area(Shape.None)
    println Shape.Circle(1.5)
end
Output12.56 12 0 Circle(radius = 1.5)

In a pattern, _ skips a value you do not need, and Voucher v binds the whole case to one name, so you can read its fields as v.code. Cases with data compare with == on all of their values, and name() and Count() work as they do on a plain enum:

enum-payment.salam
enum Payment:
    Cash
    Card(last4: str, amount: int)
    Voucher(code: str)
end

func label(p: Payment): str:
    ret match p:
        Cash: "cash" end
        Card(digits, _): "card ending " + digits end
        Voucher v: "voucher " + v.code end
    end
end

func main:
    payments := [Payment.Cash, Payment.Card("4242", 120), Payment.Voucher("SPRING")]
    each p in payments:
        println p.name(), "->", label(p)
    end
    println Payment.Card("4242", 120) == payments[1], payments[0] == payments[2]
    println "kinds of payment:", Payment.Count()
end
OutputCash -> cash Card -> card ending 4242 Voucher -> voucher SPRING true false kinds of payment: 3

An enum with data needs at least two cases, and its cases cannot also have = value.

Generic enums

An enum that carries data can be generic too. Maybe<T> either holds a value or holds nothing: a safe way to say "there is no result" without a null.

enum-generic.salam
enum Maybe<T>:
    Some(value: T)
    None
end

func divide(a: int, b: int): Maybe<int>:
    if b == 0:
        ret Maybe.None
    end
    ret Maybe.Some(a / b)
end

func describe(m: Maybe<int>): str:
    ret match m:
        Some(v): "the result is " + v end
        None: "division by zero" end
    end
end

func main:
    println describe(divide(10, 2))
    println describe(divide(1, 0))
end
Outputthe result is 5 division by zero

Type parameters themselves are covered in generics and interfaces.

Result and the ? operator

Salam has no exceptions. The result package provides Result<T, E>, which is either Ok with a value or Err with an error. Inside a function that returns a Result, writing ? after a call unwraps the successful value, and on an error returns that error straight away.

result-try.salam
import result

func number(text: str): result.Result<int, str>:
    if text == "7":
        ret result.Result.Ok(7)
    end
    ret result.Result.Err("not a valid number: " + text)
end

func add(a: str, b: str): result.Result<int, str>:
    x := number(a)?
    y := number(b)?
    ret result.Result.Ok(x + y)
end

func main:
    println add("7", "7")
    println add("7", "seven")
end
OutputOk(value = 14) Err(error = "not a valid number: seven")

The ? has to be the whole value of its statement: x := f()?, x = f()?, f()? or ret f()?. A defer in the function still runs when ? returns early.

Working with a Result

Where the error does not need to travel further, the package has small helpers. result.IsOk and result.IsErr tell you which case you have, result.UnwrapOr(r, fallback) gives the value or a fallback, and result.Expect(r, message) gives the value or stops the program with the message. A Result is an ordinary enum, so match works on it too.

result-helpers.salam
import result

func parse_age(text: str): result.Result<int, str>:
    n := text.to_int()
    if n <= 0 or n > 150:
        ret result.Result.Err("not an age: " + text)
    end
    ret result.Result.Ok(n)
end

func main:
    good := parse_age("42")
    bad := parse_age("abc")
    println result.IsOk(good), result.IsErr(bad)
    println result.UnwrapOr(bad, 18)
    println result.Expect(good, "an age is required")
    println match bad:
        Ok(age): "age " + age end
        Err(msg): "error: " + msg end
    end
end
Outputtrue true 18 42 error: not an age: abc

Keep Expect for errors that mean a bug in the program, not for bad input from a user: when it fails, the program ends with salam panic: followed by your message.

Rules of thumb

  • Use an enum whenever a value is one of a known, fixed list. A string can be misspelled or written in the wrong case ("shipped" where the code expects "Shipped") without anyone noticing, while a wrong enum case does not compile.
  • Prefer match without else on your own enums, so the compiler finds every place a new case must be handled.
  • Check numbers that come from outside the program before converting them with as.
  • Give each case the data that belongs to it, instead of one struct with fields that are only sometimes used.
  • Return a Result from functions that can fail, and pass errors up with ?.