Keys to Elm: Type annotations

azurewaters
6 min readDec 22, 2022

--

It’s easy for people to forget how hard they found something when they were beginners. The “Keys to Elm” series tries to teach a few things that will hopefully make learning Elm a bit easier.

Photo by Ashkan Forouzani on Unsplash

Note: This article assumes you have some familiarity with the Elm language. Hopefully, you’ve at least finished the “Types” chapter of the official guide.

Introduction

In Elm, type annotations are lines above functions that tell you what the type of each argument is and also the function’s return type. Here’s an example of a function with a type annotation:

save : String -> Cmd msg      -- This is the type annotation
save markdown = -- The actual function's definition starts here
Download.string "draft.md" "text/markdown" markdown

What you may be able to intuit is that the argument markdown is of String type. The return type of the function is the one at the very end of the type annotation. In this particular case, the save function returns aCmd msg.

You’ll encounter type annotations in the documentation and in code examples. Just being able to make sense of the type annotations will have you well on your way to understanding code, writing it, and debugging it.

Examples

Example 1

Let’s start with a simple example of a type annotation:

round : Float -> Int

The above means that round is a function that takes a Float as an argument and returns an Int value. As mentioned above, the return type of the function is the very last type in the annotation.

Example 2

Here’s another:

modBy : Int -> Int -> Int

This just means that the modBy function takes 2 arguments, both Ints, and outputs an Int. Again, the last type of the type annotation tells us what the type of the function’s output is.

Example 3

The difficulty level ups to intermediate in this one:

identity : a -> a

Notice how the a is lower-cased? This means that it can be of any type — an Int, a Float, a Char, a String, a user-defined type. Anything at all. They are therefore called type variables.

Further, the fact that the input and output of the function is type a means that the function will return a value that is of the same type as the argument it was given. To be clear: this does not mean that if you send in a 20, you will get back a 20; all that we can tell from the signature is that sending in an Int will result in an Int.

Example 4

A slightly more complicated example:

always : a -> b -> a

Here, we learn from the signature that always is a function that takes two arguments: one of a type and one of b type.

From the previous example, we learned that a and b could be anything: an Int, a Float, a Bool, a String, a user-defined type — anything at all.

However, since the output of the function is of type a, it means that the function will return a value that will be of the same type as the first argument.

Example 5

Ready to up the ante? Here goes:

map : (a -> b) -> List a -> List b

The second argument to the map function is List a, meaning it is a list of a type elements. The output of the map function is a list of b type values.

The first argument to the map function is “(a -> b)”. The parentheses signifies that it is a function. In other words, the map function expects a function as its first argument. Specifically, it wants a function that takes an argument of type a and returns a value of type b.

Now that the type signature is clear to you, it doesn’t take much to deduce that the map function takes a list of a values and passes each of them through the (a -> b) function to get a list of b values, which is sent out as the result of the map function.

Example 6

Now, take a look at a special case. Here’s the onClick function’s signature:

onClick : msg -> Attribute msg

You might have noticed that msg is lower-cased, just like a and b were in the previous examples. As we’ve learned so far, lower-cased names like a and b mean that the “type variable” can be of any type. However, as per convention in Elm, we are expected to send inMsgs here. The return value of the function will be an Attribute msg, meaning that the value you sent in will come out wrapped in an Attribute.

Note: I haven’t gone into what identity or always or any of the functions in the above examples do because that’ll distract us from the task of learning to read types.

Using this skill

Prevent errors

Elm will infer types even if you don’t annotate your code and your code will not compile if the wrong type of arguments are used. Start fixing errors in your code by checking the type annotations, especially if you are a beginner or are using functions you aren’t familiar with.

Find appropriate functions to use

Let’s suppose you wanted to make HTTP calls. Here’s the type signature of the Http.get function:

Http.get :
{ url : String
, expect : Expect msg
}
-> Cmd msg

The Http.get function wants a record with the second field of Expect msg type. Looking through the documentation, you’ll see that there are 4 functions whose outputs are of the Expect msg type:

Http.expectWhatever : (Result Error () -> msg) -> Expect msg

Http.expectString : (Result Error String -> msg) -> Expect msg

Http.expectJson : (Result Error a -> msg) -> Decoder a -> Expect msg

Http.expectBytes : (Result Error a -> msg) -> Decoder a -> Expect msg

So, we just pick one of these functions depending on whether we want String, JSON or Bytes. And, then we plug it into the record of the Http.get call. Like so:

-- Assume that GotSampleText is a Msg in your app
Http.get :
{ url : “https://www.example.com/sampletext
, expect : Http.expectString GotSampleText
}

Substitute values with functions

Since Elm is a functional language, you can pass around functions just as easily as you pass around values. In other words, if a function expects an argument of a certain type, you aren’t restricted to sending in only a value of that type to it, you can just as easily send in a second function whose return type will be the same as the expected type; values and functions are interchangeable as long as the types match.

Suppose we have two functions:

-- Here's the signature of a hypothetical function that takes a number
-- and outputs something like, "The total is $70"
whatsTheTotal : Int -> String

-- And, here's the signature of another hypothetical function that
-- totals a list of numbers
totalThese : List Int -> Int

-- You could use the first function in this way:
whatsTheTotal 70

-- Or, you could use it this way:
whatsTheTotal (totalThese [10, 20, 40])

Since the return type of totalThese is an Int (the same as the input argument for whatsTheTotal), you can simply pass in the function itself. There’s no need to invoke functions, put their values into a variable, and then supply that value into another function. It’s all done in a single step.

Conclusion

No, people don’t learn to read types overnight. It’ll take practice. However, I hope I have you convinced to pay more attention to the type annotations. Cheers!

If you’d like, you can support my caffeine habit with a virtual coffee!

--

--