4.6

Type System

Back in the Immutability section, we wrote a function in JavaScript that doubled the highest scores from regular season games in NBA history.

var scoreMultiplier = 2;
var highestScores = [316, 320, 312, 370, 337, 318, 314];

function doubleScores(scores) {
    var newScores = [];

    for (var i = 0; i < scores.length; i++) {
        newScores[i] = scores[i] * scoreMultiplier;
    }

    return newScores;
}

When we gave it a list of numbers as an input, it produced an expected output.

> highestScores
[316, 320, 312, 370, 337, 318, 314]

> doubleScores(highestScores)
[632, 640, 624, 740, 674, 636, 628]

What happens when we give it an input of a different type — one that’s not a list of numbers?

> var string = "highestScores"

> doubleScores(string)
[NaN, NaN, NaN, NaN, NaN, NaN]
> var undefinedValue = undefined

> doubleScores(undefinedValue)

Uncaught TypeError: Cannot read property 'length' of undefined
    at doubleScores (experiment.js:7)
    at <anonymous>:1:1
> var object = { "key" : "value" }

> doubleScores(object)
[]

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:

function doubleScores(scores) {

    // Reject non-list type inputs
    if (Array.isArray(scores) === false) {
        throw new Error("Input must be of type array");
    }

    var newScores = [];

    for (var i = 0; i < scores.length; i++) {
        newScores[i] = scores[i] * scoreMultiplier;
    }

    return newScores;
}

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.

> var string = "highestScores"

> doubleScores(string)

Uncaught Error: Input must be of type array
    at doubleScores (experiment.js:8)
    at <anonymous>:1:1
> var undefinedValue = undefined

> doubleScores(undefinedValue)

Uncaught Error: Input must be of type array
    at doubleScores (experiment.js:8)
    at <anonymous>:1:1
> var object = { "key" : "value" }

> doubleScores(object)

Uncaught Error: Input must be of type array
    at doubleScores (experiment.js:8)
    at <anonymous>:1:1

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.

> var undefinedArray = [undefined]

> doubleScores(undefinedArray)
[NaN]
> var stringArray = ["highestScores"]

> doubleScores(stringArray)
[NaN]
> var object = { "key" : "value" }

> doubleScores([object])
[NaN]

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:

function doubleScores(scores) {

    // Reject non-list type inputs
    if (Array.isArray(scores) === false) {
        throw new Error("Input must be of type array");
    }

    var newScores = [];

    for (var i = 0; i < scores.length; i++) {

        // Reject arrays that contain values that are not numbers
        if (typeof scores[i] !== "number") {
            throw new Error("Input array must contain numbers only");
        }
        else {
            newScores[i] = scores[i] * scoreMultiplier;
        }
    }

    return newScores;
}

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.

> var undefinedArray = [undefined]

> doubleScores(undefinedArray)

Uncaught Error: Input array must contain numbers only
    at doubleScores (experiment.js:17)
    at <anonymous>:1:1
> var stringArray = ["highestScores"]

> doubleScores(stringArray)

Uncaught Error: Input array must contain numbers only
    at doubleScores (experiment.js:17)
    at <anonymous>:1:1
> var object = { "key" : "value" }

> doubleScores([object])

Uncaught Error: Input array must contain numbers only
    at doubleScores (experiment.js:17)
    at <anonymous>:1:1

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.

scoreMultiplier =
    2


highestScores =
    [ 316, 320, 312, 370, 337, 318, 314 ]


doubleScores scores =
    List.map (\x -> x * scoreMultiplier) scores

Let’s start with a string. Fire up an elm repl session from the beginning-elm directory in terminal and enter the following code.

> import Playground exposing (..)

> doubleScores "highestScores"

--------------------- TYPE MISMATCH --------------------
The 1st argument to `doubleScores` is not what I expect:

5|   doubleScores "highestScores"
                  ^^^^^^^^^^^^^^^
This argument is a string of type:

    String

But `doubleScores` needs the 1st argument to be:

    List number

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?

> doubleScores [ "highestScores" ]

--------------------- TYPE MISMATCH --------------------
The 1st argument to `doubleScores` is not what I expect:

5|   doubleScores [ "highestScores" ]
                  ^^^^^^^^^^^^^^^^^^^
This argument is a list of type:

    List String

But `doubleScores` needs the 1st argument to be:

    List number

It’ll keep rejecting until we give it a list of numbers.

> doubleScores [ 316, 320, 312, 370, 337, 318, 314 ]
[632,640,624,740,674,636,628]

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.

> 1
1 : number

> 2.5
2.5 : Float

> True
True : Bool

> "dudeism"
"dudeism" : String

: 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.

> x = 1
1 : number

> 5.3 + x
6.3 : Float

> 5 + x
6 : number

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:

> x + (String.length "Duder")
6 : 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 church of " ++ "the latter-day dude"
"The church of the latter-day dude" : String

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.

> [ 1.0, 2.0 ]
[1,2] : List Float

> [ "Jackie", "Treehorn" ]
["Jackie","Treehorn"] : List String

We get List followed by the type of values the list contains. How about an empty list?

> []
[] : List a

Instead of a concrete type, we get a, which means the type can vary depending on how we use the empty list.

> 1.5 :: []
[1.5] : List Float

> "Maude" :: []
["Maude"] : List String

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

> import Array

> Array.fromList [ 1, 2, 3 ]
Array.fromList [1,2,3]
    : Array.Array number

> Array.fromList [ "You", "Human", "Paraquat" ]
Array.fromList ["You","Human","Paraquat"]
    : Array.Array String

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

> ( 1, 2 )
(1,2) : ( number, number1 )

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.

> ( 1, 2, 3 )
(1,2,3) : ( number, number1, number2 )

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 Ints or two Floats. 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:

> ( 1, 2 )
(1,2) : ( number, number1 )

> ( 1, 2.0 )
(1,2) : ( number, Float )

> ( 1.0, 2 )
(1,2) : ( Float, number )

> ( 1.0, 2.0 )
(1,2) : ( Float, Float )

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:

> ( "Bunny", "Uli" )
("Bunny","Uli") : ( String, String )

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.

> [ ( 1, 2 ), ( 3, 4, 5 ) ]

--------------------------- TYPE MISMATCH ----------------------------
The 2nd element of this list does not match all the previous elements:

7|   [ ( 1, 2 ), ( 3, 4, 5 ) ]
                 ^^^^^^^^^^^
The 2nd element is a tuple of type:

    ( number, number1, number2 )

But all the previous elements in the list are:

    ( number, number1 )

Record Type

> { name = "The Big Lebowski", releaseYear = 1998 }
{ name = "The Big Lebowski", releaseYear = 1998 }
    : { name : String, releaseYear : number }

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 Movie = { name : String, releaseYear : Int }

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:

> Movie "The Big Lebowski" 1998
{ name = "The Big Lebowski", releaseYear = 1998 }
    : Movie

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.

> addOne y = y + 1
<function> : number -> number

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.

addOne : number -> number
addOne y =
    y + 1

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.

> (+)
<function> : number -> number -> number

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.

> divideByTwo z = z / 2
<function> : Float -> Float

> divideByTwo z = z // 2
<function> : Int -> Int

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.

guardiansWithShortNames guardians =
    guardians
        |> List.map String.length
        |> List.filter (\x -> x < 6)
        |> List.length

And expose guardiansWithShortNames in the module definition.

module Playground exposing
    ( doubleScores
    , guardiansWithShortNames
    .
    .

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.

> import Playground exposing (..)

> guardiansWithShortNames
<function> : List String -> Int

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.

> List.length
<function> : List a -> Int

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:

{- Takes a list of strings and
   returns an integer value
-}
guardiansWithShortNames guardians =
    guardians
        |> List.map String.length
        |> List.filter (\x -> x < 6)
        |> List.length

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

guardiansWithShortNames : List String -> Int
guardiansWithShortNames guardians =
    ...

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 : number -> number -> number

Add the following function definition right above main in Playground.elm.

add : number -> number -> number
add num1 num2 =
    num1 ++ num2

And expose add in the module definition.

module Playground exposing
    ( add
    .
    .

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.

> add 1 2

------------------- TYPE MISMATCH ------------------
The (++) operator cannot append this type of value:

221|     num1 ++ num2
         ^^^^
This `num1` value is a:

    number

Hint: Only strings, text, and lists are appendable.

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:

add : appendable -> appendable -> appendable

Which is not what we want. Go ahead and replace ++ with + in the add function so that its implementation matches the type annotation.

add : number -> number -> number
add num1 num2 =
    num1 + num2

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:

add : Int -> Int -> Int

Now if we try to add two Float values, the compiler will throw an error.

> add 1.5 2.3

----------------- TYPE MISMATCH ---------------
The 1st argument to `add` is not what I expect:

5|   add 1.5 2.3
         ^^^
This argument is a float of type:

    Float

But `add` needs the 1st argument to be:

    Int

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.

> add 1 2
3 : Int

> add 1
<function> : Int -> Int

When we pass only the first argument to add, it returns a function that looks something like this behind the scenes:

addPartiallyApplied : Int -> Int
addPartiallyApplied num2 =
    1 + num2

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.

> addPartiallyApplied = add 1
<function> : Int -> Int

Now we can apply addPartiallyApplied to 2 and get our final result.

> addPartiallyApplied 2
3 : Int

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:

add : Int -> (Int -> Int)

Since parentheses indicate a function, if we continue to wrap each individual function in parenthesis, the type annotation will look like this:

add : (Int -> (Int -> Int))

However, Elm makes all those parentheses optional so that our type annotations can look much cleaner. That’s how we ended up with this:

add : Int -> Int -> Int
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:

> add 2 4

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:

> type Greeting = Howdy

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.

> Howdy
Howdy : Greeting

As expected, the type is listed as Greeting. What happens if we try to print the Greeting type itself.

> Greeting

------------ NAMING ERROR -------------
I cannot find a `Greeting` constructor:

7|   Greeting
     ^^^^^^^^

We get a naming error. We’ll get the same error if we also try to print other types provided by Elm.

> String

------------ NAMING ERROR -----------
I cannot find a `String` constructor:

7|   String
     ^^^^^^

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.

> type Greeting = Howdy | Hola

Now Greeting has two possible values: Howdy and Hola.

> Howdy
Howdy : Greeting

> Hola
Hola : Greeting

Greeting’s definition looks very similar to how Bool is defined in Elm.

type Bool = False | True

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.

type Greeting
    = Howdy
    | Hola


sayHello : Greeting -> String
sayHello greeting =
    case greeting of
        Howdy ->
            "How y'all doin'?"

        Hola ->
            "Hola amigo!"

And expose Greeting(..) and sayHello in the module definition.

module Playground exposing
    ( Greeting(..)
    , sayHello  
    .
    .

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.

welcomeMessage : Bool -> String
welcomeMessage isLoggedIn =
    case isLoggedIn of
        True ->
            "Welcome to my awesome site!"

        False ->
            "Please log in."

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.

> import Playground exposing (..)

> sayHello Howdy
"How y'all doin'?" : String

> sayHello Hola
"Hola amigo!" : String

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.

------------------ TYPE MISMATCH -------------------
The 1st argument to `sayHello` is not what I expect:

6|   sayHello Howdy
              ^^^^^
This `Howdy` value is a:

    Elm_Repl.Greeting

But `sayHello` needs the 1st argument to be:

    Playground.Greeting

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:

type Greeting
    = Howdy
    | Hola

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:

> type alias Movie = { name : String, releaseYear : Int }

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:

> Movie "The Big Lebowski" 1998
{ name = "The Big Lebowski", releaseYear = 1998 }
    : Movie

Here’s another example from the Url module:

type alias Url =
    { protocol : Protocol
    , host : String
    , port_ : Maybe Int
    , path : String
    , query : Maybe String
    , fragment : Maybe String
    }

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.

releasedIn2016 : Movie -> Bool
releasedIn2016 movie =
    movie.releaseYear == 2016

If we hadn’t created the Movie type alias, the type annotation would look like this:

releasedIn2016 : { name : String, releaseYear : Int } -> Bool
releasedIn2016 movie =
    movie.releaseYear == 2016

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:

type Greeting
    = Howdy
    | Hola
    | Namaste String
    | NumericalHi Int Int


sayHello : Greeting -> String
sayHello greeting =
    case greeting of
        Howdy ->
            "How y'all doin'?"

        Hola ->
            "Hola amigo!"

        Namaste message ->
            message

        NumericalHi value1 value2 ->
            value1 + value2 |> String.fromInt

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.

> Namaste
<function> : String -> Greeting

> NumericalHi
<function> : Int -> Int -> Greeting

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.

> Namaste "Tapailai kasto cha?"
Namaste ("Tapailai kasto cha?") : Greeting

> NumericalHi 1 4
NumericalHi 1 4 : Greeting

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.

> Howdy
Howdy : Greeting

> Hola
Hola : Greeting

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 with NumericalHi.

The third set that contains elements tagged with Namaste looks like this:

{ (Namaste "a"), (Namaste "b"), (Namaste "aaramai?"), ... }

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:

{ (NumericalHi 0 0), (NumericalHi 0 1), (Numerical 0 2), ... }

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:

type Greeting
    = Howdy
    | Hola
    | Namaste String
    | NumericalHi Int Int
    | NumericalHi Int Int Int

Now try to print NumericalHi in the repl.

> NumericalHi

---------------------------- NAME CLASH -----------------------------
This file defines multiple `NumericalHi` type constructors. One here:

231|     | NumericalHi Int Int
           ^^^^^^^^^^^
And another one here:

232|     | NumericalHi Int Int Int
           ^^^^^^^^^^^
How can I know which one you want? Rename one of them!

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.

type Salutation
    = Aloha
    | Howdy

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.

> Howdy

------------------------- NAME CLASH --------------------------
This file defines multiple `Howdy` type constructors. One here:

233|     = Howdy
           ^^^^^
And another one here:

229|     | Howdy
           ^^^^^
How can I know which one you want? Rename one of them!

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.

sayHello : Greeting -> String
sayHello greeting =
    case greeting of
        Howdy ->
            "How y'all doin'?"

        Hola ->
            "Hola amigo!"

        Namaste message ->
            message

Now if we try to print sayHello in the repl, we get the following error.

> sayHello

------------------ MISSING PATTERNS ----------------------
This `case` does not have branches for all possibilities:

236|>    case greeting of
237|>        Howdy ->
238|>            "How y'all doin'?"
239|>
240|>        Hola ->
241|>            "Hola amigo!"
242|>
243|>        Namaste message ->
244|>            message

Missing possibilities include:

    NumericalHi _ _

I would have to crash if I saw one of those. Add branches for them!

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.

sayHello : Greeting -> String
sayHello greeting =
    case greeting of
        .
        .
        Namaste message ->
            ...

        NumericalHi value1 value2 ->
            value1 + value2 |> String.fromInt

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:

type Maybe a
    = Just a
    | Nothing

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.

> import Array

> myArray = Array.fromList [ 0, 1, 2, 3, 4 ]
Array.fromList [0,1,2,3,4]
    : Array.Array number

> Array.get 10 myArray
Nothing : Maybe number

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:

get : Int -> Array a -> Maybe a

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.

> Nothing
Nothing : Maybe a

> Just 5
Just 5 : Maybe number

> Just "Andre the Giant"
Just ("Andre the Giant") : Maybe String

> Just [ 1, 2, 3 ]
Just [1,2,3] : Maybe (List number)

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.

type Greeting
    = Howdy
    | Hola
    | Namaste String
    | NumericalHi Int Int

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.

type Greeting a
    = Howdy
    | Hola
    | Namaste a
    | NumericalHi Int Int

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.

> Namaste 5
Namaste 5 : Greeting number

> Namaste "aaramai?"
Namaste "aaramai?" : Greeting String

> Namaste [ 1, 2, 3 ]
Namaste [1,2,3] : Greeting (List number)

Its type signature has also changed.

> Namaste
<function> : a -> Greeting a

Before adding a type argument, it was this:

> Namaste
<function> : String -> Greeting

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:

type Greeting a
    = Howdy
    | Hola
    | Namaste String
    | NumericalHi Int Int

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:

sayHello : Greeting a -> String

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:

type Greeting a
    = Howdy
    | Hola
    | Namaste a
    | NumericalHi Int Int

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.

> sayHello

------------------------ TYPE MISMATCH ------------------------
Something is off with the 3rd branch of this `case` expression:

244|             message
                 ^^^^^^^
This `message` value is a:

    a

But the type annotation on `sayHello` says it should be:

    String

Hint: Your type annotation uses type variable `a` which means 
ANY type of value can flow through, but your code is saying it 
specifically wants a `String` value. Maybe change your type 
annotation to be more specific? Maybe change the code to be more 
general?

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:

type Greeting
    = Howdy
    | Hola
    | Namaste String
    | NumericalHi Int Int

Since Greeting doesn’t accept a type variable anymore, we need to modify the sayHello function’s type annotation to this:

sayHello : Greeting -> String

Multiple Type Arguments

Elm allows us to have multiple arguments in a type definition. Here’s an example:

type Result error value
    = Ok value
    | Err error

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.

$ elm install elm/json

If you see the following message, answer Y.

I found it in your elm.json file, but in the "indirect" dependencies.
Should I move it into "direct" dependencies for more general use? [Y/n]:

Now run the following code in elm repl.

> import Json.Decode exposing (decodeString, int)

> decodeString int "42"
Ok 42 : Result Json.Decode.Error Int

> decodeString int "hello"
Err (Failure ("This is not valid JSON! Unexpected token h in JSON at position 0") <internals>)
    : Result Json.Decode.Error Int

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:

decodeString : Decoder a -> String -> Result Error a

To create a value of type Result, we must use one of these data constructors: Ok and Err.

> Ok 5
Ok 5 : Result error number

> Ok "hello"
Ok "hello" : Result error String

> Ok [ 1, 2, 3 ]
Ok [1,2,3] : Result error (List number)

> Err "Operation failed because you entered invalid data."
Err ("Operation failed because you entered invalid data.")
    : Result String value

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.

signUp : String -> String -> Result String String
signUp email ageStr =
    case String.toInt ageStr of
        Nothing ->
            Err "Age must be an integer."

        Just age ->
            let
                emailPattern =
                    "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b"

                regex =
                    Maybe.withDefault Regex.never <|
                        Regex.fromString emailPattern

                isValidEmail =
                    Regex.contains regex email
            in
            if age < 13 then
                Err "You need to be at least 13 years old to sign up."

            else if isValidEmail then
                Ok "Your account has been created successfully!"

            else
                Err "You entered an invalid email."

Now expose signUp in the module definition.

module Playground exposing
    .
    .
    , signUp
    )

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.

> import Playground exposing (..)

> signUp "thedude@rubix.com" "48"
Ok ("Your account has been created successfully!")
    : Result String String

> signUp "@sobchaksecurity.com" "51"
Err ("You entered an invalid email.") : Result String String

> signUp "walter@sobchaksecurity.com" "11"
Err ("You need to be at least 13 years old to sign up.")
    : Result String String

> signUp "bunny@jackietreehorn.com" "aa"
Err ("Age must be an integer.") : Result String String

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.

type alias Character =
    { name : String
    , age : Maybe Int
    }


sansa : Character
sansa =
    { name = "Sansa"
    , age = Just 19
    }


arya : Character
arya =
    { name = "Arya"
    , age = Nothing
    }


getAdultAge : Character -> Maybe Int
getAdultAge character =
    case character.age of
        Nothing ->
            Nothing

        Just age ->
            if age >= 18 then
                Just age

            else
                Nothing

And expose Character, sansa, arya, and getAdultAge in the module definition.

module Playground exposing
    .
    .
    , Character
    , sansa
    , arya
    , getAdultAge
    )

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.

type alias Character =
    { name : String
    , age : Maybe Int
    }

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.

sansa : Character
sansa =
    { name = "Sansa"
    , age = Just 19
    }


arya : Character
arya =
    { name = "Arya"
    , age = Nothing
    }

The Maybe type constructor also shows up in the getAdultAge function’s type annotation.

getAdultAge : Character -> Maybe Int

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.

getAdultAge character =
    case character.age of
        Nothing ->
            Nothing

        Just age ->
            if age >= 18 then
                Just age
            else
                Nothing

Let’s see how the getAdultAge function behaves when we give it a character whose age is present.

> getAdultAge sansa
Just 19 : Maybe Int

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?

> getAdultAge arya
Nothing : Maybe Int

As expected, we get Nothing. Let’s create three more characters in repl to further explore the getAdultAge function’s behavior.

> jonSnow = Character "Jon Snow" <| Just 21
{ age = Just 21, name = "Jon Snow" }
    : Character

> rickon = Character "Rickon" <| Just 11
{ age = Just 11, name = "Rickon" }
    : Character

> robb = Character "Robb" <| Just 18
{ age = Just 18, name = "Robb" }
    : Character

What happens if we give a character whose age is less than 18 to getAdultAge?

> getAdultAge rickon
Nothing : Maybe Int

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.

> List.filterMap getAdultAge [ sansa, arya, jonSnow, rickon, robb ]
[19,21,18] : List Int

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:

> List.filterMap
<function> : (a -> Maybe b) -> List a -> List b

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 returns Nothing.

  • 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:

> []
[] : List a

> 9 :: []
[9] : List number

> 31 :: [ 9 ]
[31,9] : List number

> 5 :: [ 31, 9 ]
[5,31,9] : List number

> 16 :: [ 5, 31, 9 ]
[16,5,31,9] : List number

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.

type MyList a
    = Empty
    | Node a (MyList a)

And expose MyList in the module definition.

module Playground exposing
    .
    .
    , MyList(..)
    )

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.

> Empty
Empty : MyList a

> Node 9 Empty
Node 9 Empty : MyList number

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.

> Node 31 (Node 9 Empty)
Node 31 (Node 9 Empty)
    : MyList number

> Node 5 (Node 31 (Node 9 Empty))
Node 5 (Node 31 (Node 9 Empty))
    : MyList number

> Node 16 (Node 5 (Node 31 (Node 9 Empty)))
Node 16 (Node 5 (Node 31 (Node 9 Empty)))
    : MyList number

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:

type MyList a
    = Node a (MyList a)

We’ll end up with a value like this:

Node 16 (Node 5 (Node 31 (Node 9 (Node 18 (Node 7 (Node 26 (...)))))))

... 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

sum : MyList Int -> Int
sum myList =
    case myList of
        Empty ->
            0

        Node intValue remainingNodes ->
            intValue + sum remainingNodes

And expose sum in the module definition.

module Playground exposing
    .
    .
    , sum
    )

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:

> import Playground exposing (..)

> myList = Node 1 (Node 2 (Node 3 (Node 4 Empty)))
Node 1 (Node 2 (Node 3 (Node 4 Empty)))
    : MyList number

> sum myList
10 : Int

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.

type Tree a
    = Empty
    | Node a (Tree a) (Tree a)

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:

exampleTree : Tree Int
exampleTree =
    Node '1'
        (Node '2'
            (Node '4'
                Empty
                (Node '8' Empty Empty)
            )
            (Node '5' Empty Empty)
        )
        (Node '3'
            (Node '6' Empty Empty)
            (Node '7'
                (Node '9' Empty Empty)
                Empty
            )
        )

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.

function add (x, y) {
    return x + y
}

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.

> add(1, 2)
3

> add(2.5, 4.3)
6.8

> add('a', 'b')
"ab"

> add("romeo", "juliet")
"romeojuliet"

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.

add : number -> number -> number
add x y =
    x + y

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.

> add 1 2
3 : number

> add 2.5 4.3
6.8 : Float

Next, let’s implement the add function in Haskell.

add :: Num a => a -> a -> a
add x y =
    x + y

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.

> :info Num
class Num a where
  (+) :: a -> a -> a
  (-) :: a -> a -> a
  (*) :: a -> a -> a
  negate :: a -> a
  abs :: a -> a
  signum :: a -> a
  fromInteger :: Integer -> a

:: 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.

add :: Num a => a -> a -> a

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:

add : number -> number -> number

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.

add : (x : Nat) -> (y : Nat) -> {auto smaller : LT x y} -> Nat
add x y =
    x + y

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:

add x y =
    if x < y then
        x + y
    else
        error "First argument must be smaller than the second."

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.

Back to top
Close