Reading Types

In the Core Language section of this book, we went through a bunch of interactive examples to get a feeling for the language. Well, we are going to do it again, but with a new question in mind. What type of value is that?

Primitives and Lists

Let's enter some simple expressions and see what happens:

[ { "input": "\"hello\"", "value": "\u001b[93m\"hello\"\u001b[0m", "type_": "String" }, { "input": "not True", "value": "\u001b[96mFalse\u001b[0m", "type_": "Bool" }, { "input": "round 3.1415", "value": "\u001b[95m3\u001b[0m", "type_": "Int" } ]

Click on this black box ⬆️ and the cursor should start blinking. Type in 3.1415 and press the ENTER key. It should print out 3.1415 followed by the type Float.

Okay, but what is going on here exactly? Each entry shows value along with what type of value it happens to be. You can read these examples out loud like this:

  • The value "hello" is a String.
  • The value False is a Bool.
  • The value 3 is an Int.
  • The value 3.1415 is a Float.

Elm is able to figure out the type of any value you enter! Let's see what happens with lists:

[ { "input": "[ \"Alice\", \"Bob\" ]", "value": "[\u001b[93m\"Alice\"\u001b[0m,\u001b[93m\"Bob\"\u001b[0m]", "type_": "List String" }, { "input": "[ 1.0, 8.6, 42.1 ]", "value": "[\u001b[95m1.0\u001b[0m,\u001b[95m8.6\u001b[0m,\u001b[95m42.1\u001b[0m]", "type_": "List Float" } ]

You can read these types as:

  1. We have a List filled with String values.
  2. We have a List filled with Float values.

The type is a rough description of the particular value we are looking at.

Functions

Let's see the type of some functions:

[ { "input": "String.length", "value": "\u001b[36m<function>\u001b[0m", "type_": "String -> Int" } ]

Try entering round or sqrt to see some other function types ⬆️

The String.length function has type String -> Int. This means it must take in a String argument, and it will definitely return an Int value. So let's try giving it an argument:

[ { "input": "String.length \"Supercalifragilisticexpialidocious\"", "value": "\u001b[95m34\u001b[0m", "type_": "Int" } ]

So we start with a String -> Int function and give it a String argument. This results in an Int.

What happens when you do not give a String though? Try entering String.length [1,2,3] or String.length True to see what happens ⬆️

You will find that a String -> Int function must get a String argument!

Note: Functions that take multiple arguments end up having more and more arrows. For example, here is a function that takes two arguments:

[ { "input": "String.repeat", "value": "\u001b[36m<function>\u001b[0m", "type_": "Int -> String -> String" } ]

Giving two arguments like String.repeat 3 "ha" will produce "hahaha". It works to think of -> as a weird way to separate arguments, but I explain the real reasoning here. It is pretty neat!

Type Annotations

So far we have just let Elm figure out the types, but it also lets you write a type annotation on the line above a definition. So when you are writing code, you can say things like this:

half : Float -> Float
half n =
  n / 2

-- half 256 == 128
-- half "3" -- error!

hypotenuse : Float -> Float -> Float
hypotenuse a b =
  sqrt (a^2 + b^2)

-- hypotenuse 3 4  == 5
-- hypotenuse 5 12 == 13

checkPower : Int -> String
checkPower powerLevel =
  if powerLevel > 9000 then "It's over 9000!!!" else "Meh"

-- checkPower 9001 == "It's over 9000!!!"
-- checkPower True -- error!

Adding type annotations is not required, but it is definitely recommended! Benefits include:

  1. Error Message Quality — When you add a type annotation, it tells the compiler what you are trying to do. Your implementation may have mistakes, and now the compiler can compare against your stated intent. “You said argument powerLevel was an Int, but it is getting used as a String!”
  2. Documentation — When you revisit code later (or when a colleague visits it for the first time) it can be really helpful to see exactly what is going in and out of the function without having to read the implementation super carefully.

People can make mistakes in type annotations though, so what happens if the annotation does not match the implementation? The compiler figures out all the types on its own, and it checks that your annotation matches the real answer. In other words, the compiler will always verify that all the annotations you add are correct. So you get better error messages and documentation always stays up to date!

Type Variables

As you look through more Elm code, you will start to see type annotations with lower-case letters in them. A common example is the List.length function:

[ { "input": "List.length", "value": "\u001b[36m<function>\u001b[0m", "type_": "List a -> Int" } ]

Notice that lower-case a in the type? That is called a type variable. It can vary depending on how List.length is used:

[ { "input": "List.length [1,1,2,3,5,8]", "value": "\u001b[95m6\u001b[0m", "type_": "Int" }, { "input": "List.length [ \"a\", \"b\", \"c\" ]", "value": "\u001b[95m3\u001b[0m", "type_": "Int" }, { "input": "List.length [ True, False ]", "value": "\u001b[95m2\u001b[0m", "type_": "Int" } ]

We just want the length, so it does not matter what is in the list. So the type variable a is saying that we can match any type. Let’s look at another common example:

[ { "input": "List.reverse", "value": "\u001b[36m<function>\u001b[0m", "type_": "List a -> List a" }, { "input": "List.reverse [ \"a\", \"b\", \"c\" ]", "value": "[\u001b[93m\"c\"\u001b[0m,\u001b[93m\"b\"\u001b[0m,\u001b[93m\"a\"\u001b[0m]", "type_": "List String" }, { "input": "List.reverse [ True, False ]", "value": "[\u001b[96mFalse\u001b[0m,\u001b[96mTrue\u001b[0m]", "type_": "List Bool" } ]

Again, the type variable a can vary depending on how List.reverse is used. But in this case, we have an a in the argument and in the result. This means that if you give a List Int you must get a List Int as well. Once we decide what a is, that’s what it is everywhere.

Note: Type variables must start with a lower-case letter, but they can be full words. We could write the type of List.length as List value -> Int and we could write the type of List.reverse as List element -> List element. It is fine as long as they start with a lower-case letter. Type variables a and b are used by convention in many places, but some type annotations benefit from more specific names.

Constrained Type Variables

There is a special variant of type variables in Elm called constrained type variables. The most common example is the number type. The negate function uses it:

[ { "input": "negate", "value": "\u001b[36m<function>\u001b[0m", "type_": "number -> number" } ]

Try expressions like negate 3.1415 and negate (round 3.1415) and negate "hi" ⬆️

Normally type variables can get filled in with anything, but number can only be filled in by Int and Float values. It constrains the possibilities.

The full list of constrained type variables is:

  • number permits Int and Float
  • appendable permits String and List a
  • comparable permits Int, Float, Char, String, and lists/tuples of comparable values
  • compappend permits String and List comparable

These constrained type variables exist to make operators like (+) and (<) a bit more flexible.

By now we have covered types for values and functions pretty well, but what does this look like when we start wanting more complex data structures?

results matching ""

    No results matching ""