Back in the Immutability section, we wrote a function in JavaScript that doubled the highest scores from regular season games in NBA history.
When we gave it a list of numbers as an input, it produced an expected output.
What happens when we give it an input of a different type — one that’s not a list of numbers?
It accepts the input no matter what its type is and gives us an unexpected output. When the input is a string, it generates an array of NaN
(not-a-number). JavaScript doesn’t have a data type called List
. It only has an array, which is like an Elm list and Elm array combined.
When the input is an undefined value, the doubleScores
function throws an error. Finally, when the input is an object, it returns an empty list. An object in JavaScript is similar to a record in Elm. It’s a collection of key value pairs.
We want our functions to be reliable. We want them to reject inputs that don’t belong to their input sets. Can we put up some guardrails around our functions so that they can reject invalid inputs? Let’s find out. Modify the doubleScores
function in experiment.js
located in the beginning-elm
directory like this:
We added an if
condition at the very beginning to verify that the input is in fact an array. If it’s not, we throw an error. Now let’s see how the doubleScores
function reacts when we pass invalid inputs to it. From the beginning-elm
directory, open index.html
in a browser and then open the browser console. Enter the code after the >
prompt into the console to see the results.
It consistently rejects the inputs and gives us a predictable error. This is much better than allowing any input and producing unpredictable output. Next, let’s find out what happens if we pass an array of values that are not numbers.
The doubleScores
function allows arrays with non-number values, which is also problematic. Can we put up some more guardrails so that this can also be prevented? Sure. Modify the doubleScores
function in experiment.js
like this:
We added another if
condition inside the for
loop to verify that each element in the array is of type number. Now let’s find out how the doubleScores
function reacts when we give it an array with non-number values. Reload index.html
in the browser so our changes to experiment.js
take effect.
Once again, it consistently rejects arrays that contain non-number values and gives us a predictable error. Although the doubleScores
function is more reliable now than before, we not only had to identify scenarios when it would allow bad inputs, but also take actions to mitigate those scenarios. This can get exhausting when our code base contains hundreds of functions like doubleScores
.
Next, we’ll find out how the doubleScores
function in Elm reacts when we give it inputs with invalid types. You should already have the following code in Playground.elm
located in the beginning-elm/src
directory.
Let’s start with a string. Fire up an elm repl
session from the beginning-elm
directory in terminal and enter the following code.
Note: The doubleScores
function is defined in the Playground
module. Therefore, we need to import the module before we can use the function in elm repl
.
It tells us that it’s expecting the argument to be a list. What if we give it a list of non-number values?
It’ll keep rejecting until we give it a list of numbers.
We didn’t have to put up any guardrails around doubleScores
for it to reject inputs with invalid types. That’s because Elm comes with a powerful type system that automatically throws an error when an input doesn’t match the exact type a function is expecting.
Elm’s type system lets us focus on the problem at hand instead of having to worry about how our code will behave when invalid inputs are passed. Functions in Elm also look more succinct due to the absence of type checking code. The rest of this section covers Elm’s type system in great detail.
Type
A type is a collection of values that have similar properties. For example, the type Int
represents numbers with no fractional part: -1
, 0
, 1
, 2
etc. And the type Bool
represents logical values True
and False
. When we enter a value in elm repl
, it tells us which type that value belongs to.
:
is used to separate a value from its type. It means “has type of”. When we typed 1
, the repl printed its type as number
, which means a numeric type. Its final type is determined as an Int
or a Float
depending on how it’s used.
When we add 1
to 5.3
we get a Float
. When we add it to 5
, we still get a number
because the result 6
can also be treated as either an Int
or a Float
depending on its usage. Here’s an example of x
ending up with type Int
:
The expression String.length "Duder"
returns an Int
value. So when we add x
to the result of that expression, it is forced to become an Int
value as well.
Expressions Have Types Too
Since all expressions in Elm get reduced to a value when evaluated, they too have a type.
The expression "The church of " ++ "the latter-day dude"
appends two strings. Its type is the type of whatever value it evaluates to.
Note: Up until now, the examples in this book have omitted the type printed by elm repl
after an expression. Now that we know what a type is, we won’t be doing that anymore.
List Type
Let’s see what type we get when we enter a list.
We get List
followed by the type of values the list contains. How about an empty list?
Instead of a concrete type, we get a
, which means the type can vary depending on how we use the empty list.
If we append a float, its type is List Float
, but if we append a string, its type is List String
. All concrete type names in Elm start with a capital letter. Therefore, the lowercase a
isn’t considered a type. It’s actually a type variable, which means it can be of any type. The letter a
itself doesn’t have any significance. It’s just a stand-in for the concrete type Elm will determine after evaluating an expression.
Any name that starts with a lowercase letter can be a type variable. For example, value
, placeholder
, and walter
are all valid names for a type variable. Elm just happens to use a
in most cases because it’s succinct. Earlier when we entered 1
in the repl, it printed the type as number
, which is a special type variable. We can only use number
to represent either Int
or Float
. There are very few special type variables like number
in Elm.
Array Type
Like List
, the type of an array is Array.Array
followed by the type of values the array contains. Types that aren’t pre-loaded into the repl are prefixed with the name of the module where they are defined. Since Int
, Float
, String
, and List
are automatically loaded into the repl, we don’t see a prefix attached to them.
Tuple Type
Elm doesn’t have a name for a tuple type like List
or Array
. That’s why the repl prints the type as ( number, number1 )
. number1
is also a special type variable. Basically, when a type variable starts with the word number
and ends with an actual number it has a special meaning in Elm.
You may be wondering why the type for ( 1, 2 )
is shown as (number, number1)
and not (number, number)
. After all, 1
and 2
both have the same type: number
. But remember, in Elm there are two types of numbers: Float
and Int
. Because the type (number, number)
uses exactly the same type variable, twice, that would mean our tuple would have to have exactly the same type twice — either two Int
s or two Float
s. By setting the type using two different type variables, (number, number1)
, Elm gives us the flexibility to have one Float
and one Int
, if we desire. Here are four possible sets of values a tuple with type (number, number1)
can have:
To reiterate, numbers such as 1
and 2
can be treated as either Int
or Float
depending on their usage. That’s why you don’t see the type of (1, 2.0)
as (Int, Float)
. By using the type (number, Float)
, Elm preserves the flexibility to use 1
as either an Int
or a Float
at a later point in time. Here’s what the type of a tuple containing two strings looks like:
For tuples to be of the same type in Elm, they must contain the same number and type of values. ( 1, 2 )
and ( 1, 2, 3 )
have different types. So do ( 1, 2 )
and ( "Bunny", "Uli" )
. That’s why when we try to put two tuples with same type of values, but different lengths into a list, we get an error.
Record Type
Like Tuple
, Elm doesn’t have a name for a record type either. That’s why the repl prints the type as { name : String, releaseYear : number }
. It means the name
field can only have a String
and the releaseYear
field can only have a number
. As we learned in the Record section, we can use type alias
to name the underlying structure of a record.
type alias
gives a name to an existing type. Movie
is not a new type. It’s just an alias for the type { name : String, releaseYear : Int }
. Later we’ll find out how to define our own types. type alias
also creates a function for constructing records behind the scenes. We can use that constructor function to create a record like this:
Now the type is Movie
instead of { name : String, releaseYear : Int }
.
Note: If you don’t remember how a constructor function works, the Record section has a nice diagram that explains it in great detail.
Function Type
Functions also have types.
We defined a function called addOne
that takes one argument of type number
and returns a number as well. So its type is printed as number -> number
.
When we enter a function definition into a code file, it’s best practice to write down its type annotation. Add the following function definition right above main
in Playground.elm
.
The parameter and return types are separated by ->
. We didn’t specify the type annotation for any of the functions we created before. How was Elm able to correctly identify the types of parameters and return values without the type annotation? Elm was able to do that because it can infer the types based on what operations we perform inside a function. In addOne
, we’re using the +
operator which has the following type.
It takes two numbers and returns a number. The parameter x
in addOne
must be a number
to satisfy the +
operator’s constraints. This automatic deduction of types is known as type inference. Elm makes extensive use of type inference throughout our code base so that we don’t have to specify the type of each and every value used in our programs. Let’s look at a few more examples of type inference in the repl.
When we use the floating-point division operator (/
), the divideByTwo
function’s type is inferred as Float -> Float
, but when we use the integer division operator (//
), which truncates everything after the decimal point, the type is inferred as Int -> Int
.
So far we’ve looked at simple functions that only use one operator. Let’s write a slightly more complex function and find out if Elm can infer its type too. Add the following function definition right above main
in Playground.elm
.
And expose guardiansWithShortNames
in the module definition.
We didn’t specify the type annotation for guardiansWithShortNames
because we want Elm to infer its type. Let’s see what Elm shows as its type in the repl.
The first operation we apply to the guardians
parameter helps Elm determine its type. For List.map
to be able to apply String.length
to each element in a list, those elements must be of type String
. That’s why we see List String
as the guardian
parameter’s type. Similarly, Elm determines the return type of a function from the last operation performed. In the example above, it’s List.length
, which returns an Int
value.
Therefore, Elm deduces that the guardiansWithShortNames
function also returns an Int
value. If Elm infers type automatically, then why do we still need to write the type annotation above each function declaration? Because we get the following benefits from type annotations:
- Provides documentation
- Enables code validation
- Limits types of values a function can accept
Providing Documentation
It’s often hard, for us humans, to deduce the types of parameters and return value just by looking at a function’s implementation. We can solve that problem by adding comments above each function like this:
Static comments like this are only as good as their author’s ability to write well. They could also go stale if someone decides to change the function but forgets to update the comment. A better alternative is to use a type annotation that will never go stale because if the code doesn’t match the type annotation, the Elm compiler will throw an error. Add the following type annotation right above the guardiansWithShortNames
function’s definition in Playground.elm
Enabling Code Validation
Elm can help us detect errors in our code by validating the type annotation against the actual code. Let’s say we want to write a function that adds two numbers. After some experimentation, we’ve figured out that the function’s type annotation should be:
Add the following function definition right above main
in Playground.elm
.
And expose add
in the module definition.
Note: We created a function called add
back in the Functions section. If you still have that definition in Playground.elm
, go ahead and remove it. Otherwise, you’ll get a duplicate definition error.
When we try to use the add
function in the repl, we get the following error.
In the type annotation we specified that add
accepts two numerical arguments, but our code expects those arguments to be appendable
. An appendable
is a type variable that can represent a list, string, or text — those are the only types in Elm that can be appended together using ++
. We mistakenly typed ++
instead of +
. If we hadn’t added the type annotation, Elm would have happily accepted the add
function’s definition and inferred its type to be:
Which is not what we want. Go ahead and replace ++
with +
in the add
function so that its implementation matches the type annotation.
Limiting Types of Values a Function Can Accept
Let’s say we want our add
function to accept only integer values. Modify its type annotation in Playground.elm
to this:
Now if we try to add two Float
values, the compiler will throw an error.
Without type annotation, we wouldn’t have been able to limit the arguments to only Int
values.
Type Annotation with Multiple Parameters
The type annotation for a function that accepts multiple arguments can be confusing to look at.
The return type is separated from the parameters by ->
. The parameters themselves are also separated by ->
. There’s no visual cue to tell where the parameters end and the return type begins. To understand why the type annotation uses a series of arrows, we need to first understand how a function in Elm works at a fundamental level.
In the Partial Function Application section, we learned that when we don’t pass enough arguments to a normal function, instead of getting an error we get a partially applied function.
When we pass only the first argument to add
, it returns a function that looks something like this behind the scenes:
It replaced num1
with 1
and now it’s waiting for us to pass the second argument. First, let’s assign the partially applied function to a constant.
Now we can apply addPartiallyApplied
to 2
and get our final result.
In the beginning, add
looked like a function that took two arguments and returned an Int
value, but after careful inspection we found out that it actually accepts only one argument and returns a partially applied function. All functions in Elm work in this manner no matter how many arguments they appear to take on the surface. With this new found knowledge, we can rewrite the add
function’s type annotation like this:
Since parentheses indicate a function, if we continue to wrap each individual function in parenthesis, the type annotation will look like this:
However, Elm makes all those parentheses optional so that our type annotations can look much cleaner. That’s how we ended up with this:
- Currying
- This process of evaluating a function that takes multiple arguments by converting it to a sequence of functions each taking a single argument is known as currying. This technique is what enables us to use the
|>
operator. Here’s an example we saw back in the Function section:
Exercise 4.6.1
Now that we know what type annotations are, moving forward we’ll be adding them to all functions defined in a code file. Go back to Playground.elm
and add type annotations to all functions. Once you’re done, run any function within Playground.elm
in repl. Here’s an example:
You may see a list of type errors in the repl. You may also notice that Elm is expecting a few type values we haven’t yet discussed in this book, like Order
and Html.Html.msg
. The beautiful thing about Elm is, since it tells you exactly what type value it is expecting in the error message, you should still be able to complete the exercise even though you may not be familiar with all the types.
Creating Our Own Types
So far we have only used the types provided by Elm, but sometimes those types aren’t enough when we want to describe and structure complex data processed by our applications. It’s hard for Elm to predict what type of data each application wants to process. That’s why it allows us to create our own custom types. Here’s an example:
We defined a new type called Greeting
. It’s not an Int
, String
or any of the other types Elm provides. It’s a completely separate type of its own, and it has only one possible value: Howdy
. Just like the type names Elm already comes with, all custom types must be named starting with a capital letter.
Let’s see what we get when we try to print Howdy
in the repl.
As expected, the type is listed as Greeting
. What happens if we try to print the Greeting
type itself.
We get a naming error. We’ll get the same error if we also try to print other types provided by Elm.
As it turns out, it doesn’t make sense to input a type into the repl. That’s because we’re supposed to enter a valid expression. Howdy
, 1
, and "Walter"
are values, and all values are valid expressions. That’s why we don’t get an error when we enter those values in the repl. On the other hand, Greeting
, Int
, and String
are names that represent categories of values, not values themselves.
Custom types aren’t limited to only one value. We can give them as many values as we want. Let’s extend the Greeting
type to include one more value.
Now Greeting
has two possible values: Howdy
and Hola
.
Greeting
’s definition looks very similar to how Bool
is defined in Elm.
Using Custom Types
Let’s see how we can use the new type Greeting
in our code. Add the following code right above the main
function in Playground.elm
located in the beginning-elm/src
directory.
And expose Greeting(..)
and sayHello
in the module definition.
Note: To access Howdy
and Hola
from outside the Playground
module, we have to add (..)
after Greeting
in the module definition. (..)
tells Elm to expose all data constructors inside a type. More on this later.
A custom type is often used with a case expression to pattern match a value of that type. Once a match is found, the corresponding expression is evaluated. There’s nothing special about how we use a custom type. The Bool
type provided by Elm can also be used in a similar fashion.
The sayHello
function takes one argument of type Greeting
. If the value is Howdy
, it engages in a proper Texan interaction. If the value is Hola
, it says “Hello friend!” in Spanish. Let’s import sayHello
into the repl and use it to greet a stranger.
If you see the following error, restart the elm repl
session. We defined the Greeting
type first in the repl. Later when we redefined it in Playground.elm
, the repl gets confused — hence the error message.
When typing code in a file, it’s best practice to break the definition of a custom type so that each value gets a line of its own. That’s why when we enter type Greeting = Howdy | Hola
into an Elm file and save, elm-format
automatically reformats it to:
In contrast, when typing code into the repl, it’s not necessary to break it into multiple lines because typing multiline code in the repl is tedious and requires special characters to indicate a line break.
The Difference Between type
and type alias
It’s important to note that type
and type alias
are two different concepts. type
creates a new type, whereas type alias
gives a new name to an existing type. We already saw an example of type alias
above. Here it is again:
We assigned Movie
as a name to the type { name : String, releaseYear : Int }
so that we could create a movie record more succinctly like this:
Here’s another example from the Url module:
Various functions in that module accept url as a record. To make that fact clearer, an alias called Url
has been created. Type aliases make it easier for us to write succinct code. Let’s say we want to write a function that tells us whether a movie is released in 2016
or not.
If we hadn’t created the Movie
type alias, the type annotation would look like this:
It’s hard to know from type annotation what type of information the releasedIn2016
function is expecting. But if we use a type alias, we can tell that it’s expecting a Movie
record. As our application grows, our data structures also tend to get more complex. By giving names to these complex data structures, we can write code that’s much more readable without losing all the benefits we get from the type system.
Custom Types with Payload
Elm makes it easier to describe complex data structures by letting us add a payload to each value in a custom type. To understand what a payload is, let’s add a couple more options to our Greeting
type. Modify it and the sayHello
function in Playground.elm
to this:
We added two more ways to create a value of type Greeting
. Namaste
enables us to say hi in Nepali language, and NumericalHi
allows us to greet mathematicians. Unlike Howdy
and Hola
, Namaste
and NumericalHi
aren’t values by themselves. Instead they provide a way to create values (or data). That’s why they’re called data constructors. Let’s see what we get when we print them in the repl.
Interestingly enough, data constructors are actually functions behind the scenes. They take a payload as an argument and create values of type Greeting
. In case of Namaste
, the payload only consists of a string, but the payload for NumericalHi
includes two Int
values. There’s no limit to how many and what type of data a payload can contain.
Namaste
and NumericalHi
are like functions in that they must be applied to the arguments inside their payloads to create concrete values.
Note: In Nepali language, “Tapailai kasto cha?” means “How are you?”.
It’s important to keep in mind that the data constructors don’t behave like normal functions in terms of performing an operation on data. They’re more like boxes to put data into. They don’t do anything with that data other than carry them around. That’s why when we type NumericalHi 1 4
into the repl, it spits out the same thing back. This entire expression: NumericalHi 1 4
is considered a value of type Greeting
.
Since Howdy
and Hola
take no payload, their values don’t need to be constructed. Their values are already established, which essentially makes them constants. That’s why when we print them, the repl doesn’t present them to us as functions.
The way we used Namaste
and NumericalHi
in the sayHello
function is very similar to how we used Howdy
and Hola
. The only difference is that we captured the arguments included in the payload and used them to create an appropriate response.
Although Howdy
and Hola
behave like constants, sometimes you’ll hear them being referred to as nullary data constructors. A nullary constructor is a constructor that takes no arguments.
Union Types
The Greeting
custom type we created above actually has a name in Elm. It’s called a union type. All custom types created using the type
keyword are called union types. They are sometimes referred to as tagged unions or algebraic data types (ADTs).
The term union is derived from set theory — a branch of mathematics for studying collections of distinct objects known as sets. A set can contain anything we can imagine such as numbers, people, cars, movies, nations, colors, and so on. Let’s say we have two sets of numbers:
The union of these two sets looks something like this:
Notice that the union contains only one instance of 3
and not two. Each element in a set has to be unique. We can only have 3
once. Another way to visualize a union of two sets is through a Venn diagram.
If two sets don’t have any elements in common, they are called disjoint sets and their union is called disjoint union.
In a disjoint union, it’s always possible to tell where each element came from. For example, we can tell that 2
came from set A
, and 4
came from B
. If two sets do have some elements in common, it’s still possible to create a disjoint union by tagging the elements in each set.
X
and Y
are two sets that are not disjoint because they have a common element: 3
. To create a disjoint union, first, we need to turn X
and Y
into disjoint sets by tagging each element in both sets. Tagging makes them disjoint because the letter X
is not equal to the letter Y
, even though the number 3
is equal in both cases. We can tag an element by creating a pair whose first element is the name of the set where the second element came from. X* and Y* are disjoint sets that contain tagged elements from X
and Y
.
A union type in Elm is similar to a disjoint union set of tagged elements. For example, the Greeting
type can be thought of as a disjoint union of four sets:
- A set that contains
Howdy
as its only element. - A set that contains
Hola
as its only element. - A set that contains infinite number of strings each tagged with
Namaste
. - A set that contains infinite number of two
Int
values tagged withNumericalHi
.
The third set that contains elements tagged with Namaste
looks like this:
The total number of elements in this set is equal to the total number of unique strings we can create in Elm, which is infinite. Similarly, the fourth set that contains elements tagged with NumericalHi
looks like this:
The total number of elements in this set is equal to twice the number of Int
values we can create in Elm, which is a very large number. The Greeting
type represents every single element in all four sets listed above. Now you know why the custom types in Elm are called union or tagged union types.
Duplicate Tags
Each tag in a type has to be unique. Add a duplicate tag to the Greeting
type in Playground.elm
like this:
Now try to print NumericalHi
in the repl.
As you can see, Elm gets confused by the duplicate definition because NumericalHi
is essentially a function behind the scenes and we can’t have two functions with the same name in the same scope. Elm also prevents us from creating two different types with the same tag in the same scope. Remove the duplicate NumericalHi
tag from Greeting
and define a new type called Salutation
right above Greeting
in Playground.elm
.
The new type Salutation
also includes Howdy
as one of its tags. Let’s see what we get when we try to print Howdy
in the repl.
Once again, Elm is confused. Remove the type definition for Salutation
from Playground.elm
. One big advantage of using union types in Elm is that the compiler will force us to handle all cases. For example in the sayHello
function, if we leave out even a single case from the Greeting
type, the compiler will complain. Remove the code that handles NumericalHi
tag from sayHello
in Playground.elm
.
Now if we try to print sayHello
in the repl, we get the following error.
The Elm compiler is like a friendly assistant who gently informs us when we make a mistake. We have Elm’s powerful type system to thank to for making the compiler so smart. Go ahead and add the NumericalHi
case back to the sayHello
function.
Type Constructor
In the Regular Expressions section we learned how the Maybe
type works, but we haven’t really seen its type definition. Here is how it looks:
Maybe
is a built-in type in Elm that allows us to express the idea of a missing value. Often times we are not quite sure whether a value we are looking for really exists. For example, if we try to retrieve the tenth element from an array that only contains five elements, we get Nothing
.
Instead of returning an error or crashing our program, the get
function returns a value of type Maybe
. Here is what the type annotation for get
looks like:
Like List
and Array
, Maybe
is a container, but it can have at most one value in it. That value can be of any type. To create a value of type Maybe
, we must use either the Just
data constructor or Nothing
constant.
Unlike our Greeting
type, Maybe
by itself is not a valid type. It merely provides a way for us to construct a type. That’s why it’s called a type constructor. It must be applied to another type argument for it to generate a valid type. Maybe Int
, Maybe String
, Maybe (List number)
are all valid types.
Generic (or parameterized) types such as Maybe a
can be incredibly powerful. To create our own generic types all we have to do is pass an argument to a type constructor. The Greeting
type we created earlier is not a generic type.
Data constructors that create a value of type Greeting
expect their payloads to be of certain types. Namaste
requires its payload to be a String
, and NumericalHi
requires two Int
values. Modify the type definition for Greeting
in Playground.elm
so that it accepts a type argument.
Now that we can pass a type argument to Greeting
, the Namaste
data constructor isn’t limited to just one type. It accepts a payload of any type. Before trying out the following examples, comment out the sayHello
function including its type annotation and remove it from the list of values being exposed in the module definition. Otherwise, you’ll get errors. We’ll fix that function soon.
Its type signature has also changed.
Before adding a type argument, it was this:
Notice that we used the type argument a
only with Namaste
, but not with NumericalHi
. We’re not required to pass a type argument to all data constructors. In fact, we don’t even have to pass it to any data constructor. The following type definition is perfectly valid in Elm. Modify the type definition for Greeting
to look like this:
A type argument that doesn’t get used in any of the data constructors is known as a phantom type argument. There are legitimate reasons for a phantom type argument’s existence, but the explanation of those reasons is beyond the scope of this book.
Since Greeting
and Greeting a
are two different types, we need to modify the sayHello
function’s type annotation. Uncomment the sayHello
function and expose it in the module definition once again. After that, modify its type annotation to this:
We only need to change the type annotation, but not sayHello greeting =
part in function definition, because the function parameter greeting
simply holds any value of type Greeting a
. Another way of looking at it is, if we change our type definition to type Welcome a
, we would need to change the type annotation to sayHello : Welcome a -> String
, but we could then leave our function definition as sayHello greeting =
.
The type annotation is the thing that connects the value stored in a function parameter to the correct type, not the name of the function parameter itself, which can be whatever we want. Before we move on, let’s revert the type definition for Greeting
back to this:
The only thing that changed is the Namaste
data constructor requires its payload to be of type a
instead of String
. Now if we try to print sayHello
in repl, we’ll get the following error.
According to the error message, all branches in a case
expression must return a value of the same type, but we aren’t following that rule. We’re returning a String
value for Howdy
, Hola
, and NumericalHi
, and returning a value of any type (represented by a
) for Namaste
.
Functions in Elm must return a value of only one type. Therefore, we need to either have all branches return a value of type a
or String
. Let’s revert the definition back to what it was before we introduced the type variable to get rid of the error:
Since Greeting
doesn’t accept a type variable anymore, we need to modify the sayHello
function’s type annotation to this:
Multiple Type Arguments
Elm allows us to have multiple arguments in a type definition. Here’s an example:
Like Maybe
, Result
is another built-in type in Elm. It accepts two type arguments: error
and value
. The Result
type comes very handy when an operation fails and we need to return a descriptive error message. To see it in action, let’s try to decode some JSON values and see what output we get. Install the elm/json
package by running the following command from beginning-elm
directory in terminal.
If you see the following message, answer Y
.
Now run the following code in elm repl
.
Note: The Json.Decode
module provides functions for decoding a JSON string into Elm values. We’ll cover this module in detail in chapter 6.
When we give the decodeString
function an invalid input, it returns a value of type Result
instead of crashing our program. Here’s what decodeString
’s type signature looks like:
To create a value of type Result
, we must use one of these data constructors: Ok
and Err
.
Result
type is a bit more expressive than Maybe
. Instead of just returning Nothing
, we can create a descriptive message that explains why the operation didn’t succeed. The next example shows how the Result
type can make the output of our function more descriptive. Add the following function definition right above main
in Playground.elm
.
Now expose signUp
in the module definition.
The signUp
function takes an email address and an age as inputs. First, it attempts to convert the user entered age from String
to Int
. Since the String.toInt
function returns a Maybe
type, we need to use a case
expression to handle both success and failure scenarios. If the user is at least thirteen years old and the email is valid too, we return a success message tagged with Ok
, otherwise we return an appropriate error message tagged with Err
.
The Maybe
and Result
types provide a robust mechanism for handling errors during the compile time. As a result, it’s extremely rare to have runtime errors in Elm.
Type vs Data Constructor
At this point you may be wondering where exactly in our code do type and data constructors get used. Type constructors are mainly used either in a type declaration or a type annotation, whereas data constructors are used inside a function body or when we define a top-level constant. Let’s say we want to find out which of the Stark siblings from Game of Thrones have reached adulthood. Add the following code right above main
in Playground.elm
.
And expose Character
, sansa
, arya
, and getAdultAge
in the module definition.
We defined a record called Character
that contains a character’s name and age. A concrete type Maybe Int
is assigned to the age
field to indicate that a character may choose not to disclose their age. We then created two characters: sansa
and arya
. sansa
’s age is included in the record as Just 19
, but arya
’s is listed as Nothing
which means her age is unknown.
So far, in the example above, we have encountered one type constructor (Maybe
) and two data constructors (Just
and Nothing
). Nothing
is more like a constant, but we are treating it as a nullary data constructor here. Since Character
is a type alias, it’s not considered a real type. As we can see, the Maybe
type constructor is used in the record’s type declaration.
Although Character
is not a real type, { name : String, age : Maybe Int }
is. A type alias tends to show up in places where a type constructor usually does. When we create an actual record, instead of using the Maybe
type constructor, we need to use either Just
or Nothing
data constructor.
The Maybe
type constructor also shows up in the getAdultAge
function’s type annotation.
The Just
and Nothing
data constructors are used inside the function body to create actual values of type Maybe Int
. They’re also used in the case expression to pattern match the values contained inside character.age
.
Let’s see how the getAdultAge
function behaves when we give it a character whose age is present.
In repl, a data constructor shows up in the value area, whereas a type constructor shows up in the type annotation area.
How about a character whose age is missing?
As expected, we get Nothing
. Let’s create three more characters in repl to further explore the getAdultAge
function’s behavior.
What happens if we give a character whose age is less than 18
to getAdultAge
?
Instead of returning rickon
’s actual age, it returns Nothing
because the getAdultAge
function is designed to ignore the age of characters who haven’t reached adulthood yet. We can take advantage of this behavior to do something cool: print only the age of adult characters.
In Elm, we can guess how a function works by looking at its type. Let’s give it a try. When we ask the repl for List.filterMap
’s type, here’s what we get:
The type annotation tells us that the filterMap
function takes two arguments:
-
A function that indicates whether a specific operation is successful or not for a given input. If successful, it returns a value wrapped in
Just
, otherwise it returnsNothing
. -
A list of values of type
a
.
Finally, the filterMap
function returns a list of values of type b
. In most cases, type annotation alone isn’t enough to figure out how a function actually works. For example, one of the behaviors that’s not quite apparent from the type annotation is that filterMap
discards all elements from the original list for which the getAdultAge
function returns Nothing
. It then pulls values out from the remaining Just
elements and puts them in a new list.
Recursive Types
In the How List Works Behind the Scenes section, we learned that List
in Elm is defined as a recursive type. At the time, we didn’t know enough about types to fully grasp the idea of recursive types. Now that we know what types are, we’re better positioned to understand what they are. Let’s say we have a list of numbers: [ 16, 5, 31, 9 ]
. Behind the scenes, this list is constructed like this:
We started with an empty list and added 9
in-front of it using the cons (::
) operator. We then continued to add the rest of the elements to that list one at a time. When a list is constructed like this, we can see the underlying recursive structure inherent in all lists.
The figure above shows that a list consists of nodes which themselves are lists. This is what makes the List
type recursive. Let’s create our own data structure that looks very much like List
to better understand how a recursive data structure works. Add the following type definition right above main
in Playground.elm
.
And expose MyList
in the module definition.
What the definition above means is that a list of type MyList
can be either Empty
or Node a
followed by another list (MyList a)
. A list with no elements can be represented like this: Empty
. A list with a single element is represented like this: Node a Empty
. Similarly, a list with two elements is represented like this: (Node a (Node a Empty))
and so on. Now let’s recreate the list [ 16, 5, 31, 9 ]
using our definition.
We start with an empty element and then add 9 in front of it similarly to how we built a list using the cons (::
) operator: 9 :: []
. Next, we keep adding the rest of the elements to the front.
Granted our list doesn’t look as nice as the one Elm provides, but conceptually they’re the same. Although MyList
behaves similarly to List
, we can’t use any of the functions defined in the List module. MyList
and List
are two completely different types. In the Easier Code Organization section we will reimplement one of the functions from the List
module so that it will work on MyList
too.
It’s important to note that if a recursive type doesn’t provide at least one nullary data constructor, then we end up with a value that never ends. If we remove the Empty
data constructor from MyList
:
We’ll end up with a value like this:
...
represents infinite iterations of Node a
.
Working with Recursive Types
We can use recursive types the same way we use any other union type. A case
expression is used to pattern match each individual data constructor defined in the type. Add the following function definition right above main
in Playground.elm
And expose sum
in the module definition.
sum
is a function that computes a sum of int values contained in a MyList
. We need to handle only two cases (Empty
and Node
) because those are the only two data constructors MyList
type offers. If the list is not empty, we remove an int value from the front and apply sum
recursively to the rest of the list. Here’s how we can use sum
in the repl:
The figure below shows each individual step in the execution of sum myList
expression.
Recursive types are very powerful. They enable us to define complex data structures succinctly. Let’s implement one more data structure called binary tree using a recursive type. Explaining the inner workings of a binary tree is out of scope for this book, so we’ll just look at its definition and visual illustration.
Tree
represents a binary tree — a hierarchical tree structure in which each node can have at most two children. It has many applications in programming.
The tree shown in the figure above can be implemented like this:
If you add the exampleTree
function and Tree
type definition to Playground.elm
, you’ll get a duplicate definition error because the Node
data constructor is used by both MyList
and Tree
types, which isn’t allowed. The example above is meant to show you how you can create a tree using a recursive type definition. You don’t have to actually implement it.
How Powerful is Elm’s Type System Exactly?
Elm’s type system is quite powerful, but how does it compare to the type system of other languages out there? There are so many great languages to choose from, but we’ll limit our comparison to these three: JavaScript, Haskell, and Idris. Type systems in these languages have some interesting characteristics that contrast well with Elm’s. For this exercise, we’ll use a simple function that adds two values.
Note: You don’t have to try out the code listed in the rest of this section. Just sit back and enjoy reading.
The above function is written in JavaScript. Looking at the definition, we can’t tell what type of values the add
function accepts. After some experimentation, we find out that it pretty much accepts anything.
JavaScript is a dynamically typed language, which means it doesn’t verify the type of values until a program is actually run. The types of x
and y
parameters aren’t known until we pass concrete values to the add
function during its execution. As you can see from the examples above, it can add two integers, floating-point numbers, characters, and even strings. There’s no way to tell what values we can expect during compile time. Let’s reimplement the add
function in Elm.
Elm’s type system allows us to specify exactly what type of values our functions can operate on. With the type annotation above, we’re saying that the add
function accepts two numbers and returns a number as well. A number can be either an Int
or a Float
. If we try to pass values of any other type, our program won’t even compile. Elm is a statically typed language, which means it verifies the type of values during the compile time.
Next, let’s implement the add
function in Haskell.
The add
function’s body in both Haskell and Elm looks exactly the same because Elm’s syntax is heavily derived from Haskell. Haskell’s type system also allows us to specify exactly what type of values our functions can operate on, but it’s more powerful than Elm’s. In Haskell, we can say that the add
function accepts any type of numbers not just Int
and Float
, and returns a number of that same type.
One of the reasons why Haskell’s type system is more expressive than Elm’s is due to type classes, which allow us to group common behaviors found in different types. Int
, Integral
, Float
, and Double
are different types in Haskell, but they all share one common behavior: they can be treated as numbers. So operations that can be applied to all numbers are defined in a type class called Num
. When we ask Haskell to reveal all the operations defined in Num
, we get the following list.
::
in Haskell is the same as :
in Elm. It means “has type of”. As you can see, the +
operator belongs to the Num
type class. That means our add
function can operate on any type that is a member of Num
. That’s why the type annotation for the add
function includes Num
.
Anything that comes before =>
and after ::
is a type class constraint. Num
is the type class and Num a
is a class constraint on the type variable a
. Let’s compare Haskell’s type annotation with Elm’s:
Elm’s type annotation is much more restrictive than Haskell’s. Now, you may be thinking that number
in Elm seems like a type class — it lets us say the add
function accepts a number
and not just Int
or Float
, after all. However, we were able to do that not because number
represents a type class, but because it happens to be a special type variable that stands in for both Int
and Float
types.
Note: The designers of Elm chose not to include type classes in the language due to various reasons. This issue on Github explains what those reasons are.
Lastly, let’s reimplement the same add
function in Idris.
The add
function’s body in Idris also looks exactly like the one in Elm and Haskell. That’s because Idris, too, heavily derives its syntax from Haskell. The type annotation, however, looks quite different. It says that the add
function accepts two integers and also returns an integer, but the first argument must be smaller than the second.
In Idris, the compiler will make sure that add
is never applied to two arguments if the first argument is greater than the second. Constraints like this is possible in Idris because it supports dependent types — a type whose definition depends on a value. Very few languages allow us to specify constraints like this through types. Even Haskell doesn’t have this capability. In Haskell, we can check for this constraint only during runtime like this:
To summarize, Elm’s type system allows us to specify more constraints than JavaScript’s, and Haskell’s type system allows us to specify more constraints than Elm’s. Finally, Idris’s type system allows us to specify even more constraints than Haskell’s. The more constraints we can specify at the compile time, the less chance there will be for errors to show during runtime. Therefore, Idris can prevent more bugs than Haskell, and Haskell can prevent more bugs than Elm.
One obvious question is: if Idris’s type system can prevent most bugs, why didn’t the designers of Elm adopt a type system like that? The answer to that question lies in complexity. The more powerful a type system is, more complex it is to implement. It’s also harder to learn. One of the hallmarks of Elm is that it’s easy to learn. Some of that ease comes from not having an overly complex type system. That said, Elm’s type system is still powerful enough to make those pesky runtime errors extremely rare.