Skip to main content

Meeting the Types

caution

The documentation that you're reading is a design document where most of the features you're reading are yet to be implemented. Check the Note on the Docs

One of the key features of NeoHaskell is its type system. Some people have preconceived ideas about types, and they think that they get in the way of development process too much. This is usually because either the type system of the programming language they are using is not flexible enough, or that types come as an afterthought of the development process of their system, like if it is a "necessary bad thing".

In NeoHaskell, types become your best friends. Not only because NeoHaskell's type system is quite different to type systems from other programming languages (e.g. it doesn't support inheritance, but supports super-generics) but also because it becomes a very useful design tool for your software development toolbox.

With the NeoHaskell type system, you can sketch an outline of your system, that then you can fill with the colors of an implementation. If your sketch outlines a dog, you might color it better or worse, but it will still be a dog.

Primitive types

In NeoHaskell, you've got the typical primitive types out of the box:

TypeDescriptionExample
IntInteger numbers42
BigIntBig integer numbers1234567890123456789012345678901234567890
FloatSimple precision decimal numbers3.1415
DoubleDouble precision decimal numbers3.141592653589793
BoolTrue or FalseTrue
CharSingle characters'a'

Of course there are much more many types, but you can consider these as the most basic ones.

Annotating Constants

Until now, when we wanted to create a constant, we just assigned a value to a name. In the NeoHaskell repl, we could do it like so:

neo> myConstant = 42

In this case, the compiler will automatically infer that the type of myConstant is Int. But it could be possible that we want it to be of type BigInt, instead of Int.

Given that types are a powerful tool for designing our programs, in NeoHaskell they get to be on their own line. This means, that for annotating the type of a constant, we write the type on a line above the assignment line.

We write the type of a constant by using the :: symbol, which reads as "is of type".

myConstant :: BigInt
myConstant = 42
tip

In the NeoHaskell REPL, use the command :{ to begin writing multiple lines, use the command :} to end the multi-line input.

If we wanted to try this on the REPL, we could do it like so:

neo> :{
myConstant :: BigInt
myConstant = 42
:}

neo> myConstant
42

Checking the type of something

In the NeoHaskell REPL, you have a pretty useful command to check the type of stuff, you can write :type and the name of something, and it will tell you the type:

neo> :type myConstant
myConstant :: BigInt

It replies with "myConstant is of type BigInt".

Avoiding primitive obsession

There's this concept in software development called "primitive obsession" which essentially says that using primitive types for everything is bad.

This is because they don't really tell you a story about how your program is structured, or how your data gets transformed.

A counter-argument that people usually say against "wrapper types", or types whose only reason of life is to give a name to a primitive type, is that they are not very efficient. You now would need to have both the primitive value in memory, and the wrapper that gives the type a name.

In NeoHaskell, you get to create these wrapper types with no performance penalty.

In memory, it is only stored the actual value of the primitive type, but in compilation, the compiler thinks that it is a different type.

To create a type of this kind, you use the newtype keyword and do it this way:

newtype Age = Age Int

This creates a new type called Age whose sole purpose is to be differentiated from other Ints. It also gives us a constructor function called Age that we can use to create a new Age value.

To create a constant with this type, you use it's constructor function before the actual Int value:

catAge = Age 2

Note how now, you cannot use it as another Int:

neo> catAge + 4

-- TYPE MISMATCH ----------------------------
Attempting to add two values, but the first value doesn't match the type of the second value:

catAge + 4
^^^^^^

`catAge` is of type:

Age

But `(+)` needs the 1st argument to be:

Int

Hint: Maybe try using `cast`?

The error message suggests the use of cast

, which is a function that allows you to cast a wrapper type to the type that it wraps, with no performance penalty. We can use it like so:

neo> cast catAge + 4
6

In this case, the compiler knows that catAge is of type Age, and that 4 is of type Int, so it casts catAge to Int and then adds it to 4.

Why do I have to cast it?

The reason for wrapper types is that they not only give names to other types, but also that they give you a way to differentiate them from the other types. This is useful for when you want to create functions that only accept a certain subset of types.

It is common that we create a function that accepts many Ints as its arguments, but we want to differentiate them from each other. For example, we could have a function that accepts a weight and a distance as arguments, and we want to differentiate them from each other.

Without wrapper types, we could easily mix them up, making the function call to be incorrect:

neo> :{
calculateEnergy weight distance = ...
:}

neo> myWeight = 42
neo> myDistance = 31415
neo> calculateEnergy myDistance myWeight -- Oops, we mixed them up!

To solve this, we could make use of wrapper types, but we also need to know how to specify the types of the arguments of a function. Let's check that out.

Annotating Functions

Although the compiler doesn't enforce it, you should annotate the types of the arguments. Even more, the NeoHaskell good practices recommends that you write the type of the function before beginning to write the function's implementation.

The NeoHaskell good practices recommends that you write the type of the function before beginning to write the function's implementation.

This is because, as we mentioned earlier, types are a powerful tool for designing our programs, and they are a great way to communicate the intent of our functions. But this is only true if we ensure that we have good design pieces to work with, like the wrapper types we just saw.

To annotate the arguments and result types of a function, we write the types of the arguments separated by arrows like ->, being the last one the result type. For example, if we wanted to annotate the calculateEnergy function, we could do it like so:

calculateEnergy :: Int -> Int -> Int
calculateEnergy weight distance = ...
note

It is common that newcomers find it weird that there's no separation between the arguments and the result type, like with a parenthesis or something. This is because it is very useful for a thing called partial application, which we will see in future sections.

Note how the calculateEnergy function accepts two Ints as arguments, and returns an Int as a result. But we still have the problem of mixing up the arguments, so let's fix that.

types are a lie meme

A good way of measuring whether a type annotation is good or not, is to check if it is possible to figure out the implementation of the function just by looking at the type annotation. In this case, we can't, because we don't know which argument is which, neither if we need to pass weight, distance or even maybe some other Int that we don't know about.

Given that weight and distance are two units that probably make sense in our domain, we can create wrapper types for them:

newtype Weight = Weight Int
newtype Distance = Distance Int

Now, we can use these wrapper types to annotate the arguments of the calculateEnergy function:

calculateEnergy :: Weight -> Distance -> Int
calculateEnergy weight distance = ...

Now it is much better, but we can improve it even more. Is the weight in kilograms? In pounds? In grams? Is the distance in meters? In kilometers? In miles? In light years? We don't know, and we can't know just by looking at the type annotation. Also, we have no clue about the result type, is it in joules? In calories? In kilocalories?

Let's fix that by creating more wrapper types, and changing the type annotation of the function:

newtype Kilograms = Kilograms Int
newtype Meters = Meters Int
newtype Joules = Joules Int

calculateEnergy :: Kilograms -> Meters -> Joules
calculateEnergy weight distance = ...

This is much better, if we take a quick glance at the type annotation, it is very clear what the function does, and what it expects as arguments and what it returns as a result.

calculateEnergy :: Kilograms -> Meters -> Joules

We haven't looked at the implementation, and it really doesn't matter, because there's a contract that has been specified for this function. It is a contract that we can trust, because it is enforced by the compiler.

And remember! Functions in NeoHaskell cannot fail, so if you see a function that returns a Joules, you can be sure that it will return a Joules value always, and it will never crash.

This is the true power of types, they are a powerful tool for designing our programs, and they are a great way to remove the need of having to read the implementation of a function to understand what it does.

Conclusion

In this section, we've seen how to annotate the types of constants and functions, and how to create wrapper types to avoid primitive obsession.

We have dipped our toes into what they call Domain Driven Design or DDD, which is the way of designing software that NeoHaskell inspires from.

NeoHaskell's way of approaching software development is very different to other programming languages, because it makes you think about the design of your program before you even start writing it. At first, it might seem like a lot of work, but in exchange, you get tools that make your life easier, and your head free of worries.