Let's start by answering this basic question abstractly: A type, is just a set of values. More specifically, the set of all values that have that type. For example:
bool = { true, false }
int = { ..., -2, -1, 0, 1, 2, ... }
uint = { 0, 1, 2, ... }
Another concept that will be important later is the cardinality of a type. The cardinality can simply be thought of as the number of values of that type. Cardinality is mathematically notated with the | symbol. For example:
|bool| = |{ true, false }| = 2
|int| = undefined
There are theoretically an infinite number of values of type int so we say that the cardinality is undefined (note that practically of course there are only finitely many 32/64-bit integers, but we'll keep things abstract for now).
There are certain ways we can combine and perform “operations” on types as well and we can define some interesting inductive cardinality rules for them:
|*T| = |T| + 1
|struct{ T, U }| = |T| * |U|
|[n]T| = |T|^n
Let's go through these examples:
Let's go back to Golang to remind ourselves of how enums are traditionally done:
type Level uint
const (
LevelHigh Level = iota
LevelMedium Level
LevelLow Level
)
So we would typically declare a new type that wraps something iterable such as int or uint and use the iota operator to easily declare a load of constant values of this type.
This practically works and for most Go code it's probably fine. Let's dig in a bit more though and investigate the cardinality of this type:
We can easily see that if we define type A B then |A| = |B| since for every value b of type B we have a corresponding value A(b)of type A(and to be mathematically thorough we could also assert that for every value a of type A we have a corresponding value B(a)of type B )
So, for our enum example above this means: |Level| = |uint|. Which would mean we have an infinite number of possible levels. (Ok, it's technically mathematically incorrect to write this since |uint| is undefined, but the simple analogy of |uint| = +infinity holds here). More practically speaking, this means anywhere in our Go code a programmer could write Level(456) and it would pass type check even though this isn't really a valid Level.
Let's leave the world of Golang to quickly go over what enum types really are and what their cardinality is.
An enum is a user defined type that has a fixed number of finite values with an empty constructor. Here are 2 examples:
|Enum(A, B, C, D)| = |{(A, B, C, D)}| = 4
|Enum(A, B)| = |{(A, B)}| = 2
So whenever we write enum types, we should aim to make the cardinality of the type exactly equal to the number of possible values we want it to have. e.g. for our Level enum, we should aim for a type with only 3 possible values.
So, we've seen the shortfall of defining Golang enums like type Level uint. Can we do better? Is there another type that matches our desired cardinality?
Disclaimer: ok, this is where it gets a bit pedantic! This probably isn't a good way to define enums in real Go code, but it has a satisfying “theoretical correctness” to it.
It turns out, there is such a type: *bool. We know from earlier that|*bool| = |bool| + 1 = 2 + 1 = 3. This is the cardinality we desire for our 3 different levels! We can now define type Level *bool and now we have |Level| = |*bool| = 3.
This neat trick comes from 2 rules we saw above. I'll restate them here:
|bool| = 2
|*T| = |T| + 1
We can use these to construct a type of any desired finite cardinality.
In our Level example, the three enum values would be:
LevelHigh = Level(nil)
LevelMedium = Level(ptr(false))
LevelLow = Level(ptr(true))
We can assign these values however we wish, the order is unimportant.
Let's also revisit something we said earlier:
More practically speaking, this means anywhere in our Go code a programmer could write Level(456) and it would pass type check even though this isn't really a validLevel.
We've now solved this problem, because any value that isn't one of the three defined enum values of type Level won't pass the type checker and thus won't compile!
Here's a more verbose example of defining days of the week:
type Day *****bool
DayMonday = Day(nil)
DayTuesday = Day(ptr(nil))
DayWednesday = Day(ptr(ptr(nil)))
DayThursday = Day(ptr(ptr(ptr(nil))))
DayFriday = Day(ptr(ptr(ptr(ptr(nil)))))
DaySaturday = Day(ptr(ptr(ptr(ptr(ptr(true))))))
DaySunday = Day(ptr(ptr(ptr(ptr(ptr(false))))))
It gets pretty impractical for bigger enums!
The astute Go programmers amongst you will already have spotted that I've cut some corners in the examples above. You can't actually define constant pointers in Golang. So practically the Level example would have to be implemented as something like:
type Level *bool
func LevelHigh() Level {
return nil
}
func LevelMedium() Level {
ptr := new(bool)
*ptr := false
return ptr
}
func LevelHigh() Level {
ptr := new(bool)
*ptr := true
return ptr
}
where the enum values are actually obtained from function calls rather than constant values.
We would also now need to define a custom definition of equality for ourLevel since in this example LevelHigh() != LevelHigh() // true.
Edit (added 01/08/22): Just wanted to add an additional section to this article to mention a great point that Tamas Bunth mentioned. All credit to him for this idea:
Our choice of bool as the base type was completely arbitrary and only chosen since it is a relatively small type of cardinality 2. Tamas pointed out that the type struct actually has cardinality 1 so this would serve as a much better base type since it allows us to have enums of cardinality 1.
So the Level example becomes: type Level **struct and can be defined:
LevelHigh = Level(nil)
LevelMedium = Level(ptr(nil))
LevelLow = Level(ptr(ptr(struct{}{})))