Enums
The documentation that you're reading is a design document where most of the features you're reading are yet to be implemented. Check the Note on the Docs
In the previous section, we learned how to start using NeoHaskell's type system, we learned how to annotate constants and functions, and also we saw how to start modelling our domain using wrapper types, which allowed us to avoid primitive obssession and to give concrete more meaningful names to our types, more according to the domain of our application.
There are certain cases where we might want to model a thing that can only have a finite number of values, for example, the state of a lightbulb, the colors of a rainbow, the days of the week, etc. Instead of using strings to represent these values, we use enums.
Similar to wrapper types, enums allow you to avoid primitive obsession. For example, instead of using a Bool
to represent
the state of a lightbulb, we can use an enum with two cases: On
and Off
. This way, we can't accidentally pass a Bool
that represents the state of a lightbulb to a function that expects a Bool
that represents the state of a TV.
Defining an enum
To define an enum, we use the data
keyword followed by the name of the enum and the cases that it can have, separated by
vertical bars (|
). For example, to define an enum that represents the state of a lightbulb, we can do the following:
- NeoHaskell
- TypeScript
data LightbulbState
= On
| Off
enum LightbulbState {
On,
Off,
}
You can add as many cases as you want. In fact, if your application handles some specific values of a type with infinite values like
Int
, it is much better to use an enum instead of an Int
because it will be much more clear what the function expects and
what it returns.
Imagine that you're developing a videogame, and you want to represent with how many lives does a player start. Let's suppose that a
player can only start with between two and six lives. Instead of using Int
, which has no limits, we can use an enum with different
cases. This way, we can't accidentally pass a 1
to a function that expects the number of starting lives of a
player, or have a bug down there in our code that makes the player accidentally start with one million lives:
- NeoHaskell
- TypeScript
data PlayerLives
= Two
| Three
| Four
| Five
| Six
enum PlayerLives {
Two,
Three,
Four,
Five,
Six,
}
One advantage of NeoHaskell's enums over TypeScript's (and most mainstream languages) enums is that the values of a NeoHaskell enum are the values themselves and they are not usable in place of other types that are not the enum itself, while in TypeScript, for example, the values of an enum are numbers that represent the position of the case in the enum, and they can be used in any place where a number is expected. Compare the following examples:
- NeoHaskell
- TypeScript
data PlayerLives
= Two
| Three
| Four
| Five
| Six
neo> Two + 4
-- TYPE MISMATCH ----------------------------
Attempting to add two values, but the first value doesn't match the type of the second value:
Two + 4
^^^
`Two` is of type:
PlayerLives
But `(+)` needs the 1st argument to be:
Int
enum PlayerLives {
Two,
Three,
Four,
Five,
Six,
}
console.log(PlayerLives.Two + 4); // 4
// The above weirdly prints 4 because the value of `PlayerLives.Two` is 0, and 0 + 4 = 4
It definitely is much better to have this fail at compile time than to have a weird bug where there's a value called
Two
that is actually 0
and that can be used in place of a number.
Enums with Attached Values
In addition to simple enums, NeoHaskell allows the creation of more complex enums by attaching values to enum cases. These are powerful constructs that enable enums to carry additional, context-specific data. This feature is particularly useful when a simple label (like On
or Off
) isn't sufficient to express all the necessary information about an enumeration case.
Defining Enums with Attached Values
When defining an enum in NeoHaskell, you can specify one or more values to be attached to each case. These values can be of any type, including other enums or complex types. This makes enums a flexible tool for modeling a wide variety of data.
Consider a Color
enum where a color can be represented in different color spaces. Some colors might be represented in the RGB color space, others in grayscale, and yet others as hexadecimal strings. Here's how you can define such an enum:
- NeoHaskell
- TypeScript
data Color
= Rgb Int Int Int -- Red, Green, Blue components
| Grayscale Int -- Intensity of gray
| Hex String -- Hexadecimal string
-- To create a value of type `Color`, you use the following syntax:
myFavoriteColor = Rgb 0 255 0
// In Typescript there's no way to attach values to enum cases, so we have to use a workaround
// First we define a type that represents the different color types
enum ColorType {
Rgb = "Rgb",
Grayscale = "Grayscale",
Hex = "Hex",
}
// Then we define a type that represents a color
type Color =
| { type: ColorType.Rgb; r: number; g: number; b: number }
| { type: ColorType.Grayscale; intensity: number }
| { type: ColorType.Hex; hexStr: string };
// And now we define a couple of functions to create colors
const Rgb = (r: number, g: number, b: number): Color => ({
type: ColorType.Rgb,
r,
g,
b,
});
const Grayscale = (intensity: number): Color => ({
type: ColorType.Grayscale,
intensity,
});
const Hex = (hexStr: string): Color => ({
type: ColorType.Hex,
hexStr,
});
// To create a value of type `Color`, we use the functions that we defined above
const myFavoriteColor = Rgb(0, 255, 0);
Each case of the Color
enum carries different types of values:
RGB
carries threeInt
values representing the red, green, and blue components of a color.Grayscale
carries a singleInt
representing the intensity of gray.Hex
carries aString
representing the color in hexadecimal format.
This design encapsulates the concept that a color can be represented in different ways, but ultimately, it's still a color within the domain of your application.
Benefits of Enums with Attached Values
Using enums with attached values has several benefits:
- Richer Data Modeling: You can model complex data structures in a type-safe manner, ensuring that the attached data aligns with the specific case of the enum.
- Clarity: The code clearly communicates what data is expected with each enum case, leading to self-documenting code.
- Safety: The compiler can enforce that all possible cases are handled in functions, reducing the likelihood of runtime errors. A function that doesn't handle all possible cases for an enum will fail to compile.
In summary, enums with attached values in NeoHaskell are a powerful feature for developers to express complex data structures cleanly and safely. They extend the utility of simple enums by allowing the carrying of additional information, which can be crucial for many applications that require detailed data representation and manipulation. And given the event-driven nature of NeoHaskell, enums with attached values are a natural fit for modeling events and their associated data.
Next Steps
We've learned how to define enums, and how to attach values to enum cases, but we haven't seen how to use them yet.
In the next section, we'll start learning about handling boolean conditions through the usage of if-then-else
,
and later, we will transition towards pattern matching, which will help you to use your enums.