Making Your Types Readable!
So in the last post we discussed creating our own data types using the data
keyword. This is an excellent way to describe most of our data. But there are more ways we should explore. We’ll look at two simpler alternatives to creating an full data type.
The Type Keyword
The first method we’ll discuss is the type
keyword. This keyword creates a type synonym, much like the typedef
keyword from C. We take some type, and simply give it a new name we can use to reference it.
type MyWrapperType = (String, Int, Float)
When we do this, the code’s behavior does not really change. It makes no difference whether we call the type by its new name or its old name. We can even use both in the same function! All the same pattern matching rules apply to the synonym as the original type.
myTypeFunction :: MyWrapperType -> (String, Int, Float) -> MyWrapperType
myTypeFunction (str1, int1, flt1) (str2, int2, flt2) = (str2, int1, flt1)
The type
keyword is an excellent way to clarify compound types. As you write more complicated code, you’ll find you often need to have more complicated types, like nested tuples and lists. For example, suppose some function generates a 5-tuple, and a few other functions take the type as input.
complicatedFunction :: … -> ([Int], Map String Float, Maybe String, Float, Int)
func1 :: ([Int], Map String Float, Maybe String, Float, Int) -> …
func2 :: ([Int], Map String Float, Maybe String, Float, Int) -> …
func3 :: ([Int], Map String Float, Maybe String, Float, Int) -> ...
We can dramatically clarify this code with type
:
type UserChunk = ([Int], Map String Float, Maybe String, Float, Int)
complicatedFunction :: … -> UserChunk
func1 :: UserChunk -> …
func2 :: UserChunk -> …
func3 :: UserChunk -> …
The best example of the type synonym in normal Haskell is the String
type. String is actually just a type name for a list of characters!
type String = [Char]
One possible downside to using type synonyms is the compiler may not use your synonym when telling you about errors. Thus you need to be aware of the different renamed types in your code as you analyze errors.
Newtypes
Now we’ll discuss a different way of expressing our types: the newtype
keyword. This creates a new, unique type, wrapping a single other type. For instance, we could create an Interest Rate newtype
wrapping a floating point number:
newtype InterestRate = InterestRate Float
The syntax for newtype
is just like creating an algebraic data type, except it can only use one constructor, and that constructor must have exactly one parameter. You can still use record syntax with a newtype
. One convention is to give the field a name such as “un”-(newtype name), as follows:
newtype InterestRate = InterestRate { unInterestRate :: Float }
As with ADTs, record syntax allows you to unwrap the original float value without pattern matching. Though you can do either if you want:
func1 :: InterestRate -> Float
func1 (InterestRate flt1) = flt1 + 2.5
func2 :: InterestRate -> Float
func2 intRt = (unInterestRate intRt) + 2.5
The most important point about newtypes is they DO change compile time behavior! The following code would not work:
rate :: InterestRate
rate = InterestRate 0.5
badCall :: Float
badCall = func1 rate
func1 :: Float -> Float
func1 flt1 = flt1 + 2.5
This is because func1
demands a Float
as input, but badCall
passes an InterestRate
, which is a different type. This is super helpful when you have numerous items which have the same underlying type, but actually represent different kinds of values.
Using Newtypes to Catch Errors
Let’s see how we can use newtypes to make a real program safer. Suppose you’re writing code for a bank. You might write a function taking a couple interest rates and a bank balance and performing some operations on them. If these are all represented by floating point numbers, you might get code looking like:
bankFunction :: Float -> Float -> Float -> Float
bankFunction balance intRate fedRate = balance * (1 + intRate + fedRate)
But now imagine trying to call this function. There are numerous ways to misorder the arguments, and the program will still compile without telling you about the error!
determineNewBalance :: Float -> Float
-- Wrong order!
determineNewBalance balance = bankFunction intRate fedRate balance
Where
intRate = 6.2
fedRate = 0.5
This code will have an error you can only catch at runtime (or with good testing), since we misordered the different float arguments. We want to avoid such code in Haskell when we can, because Haskell gives us the tools to do so! Let’s see how newtypes can help us avoid this error:
newtype BankBalance = BankBalance { unBankBalance :: Float }
newtype InterestRate = InterestRate { unInterestRate :: Float }
bankFunction :: BankBalance -> InterestRate -> InterestRate -> BankBalance
bankFunction (BankBalance b) (InterestRate i) (InterestRate f) = BankBalance (b * (1 + i + f))
determineNewBalance :: BankBalance -> BankBalance
determineNewBalance b = bankFunction intRate fedRate b
where
intRate = InterestRate 6.2
fedRate = InterestRate 0.5
This code, which contains the same fundamental error, will not compile anymore! This is because we pass interest rates as the first two parameters , when the first parameter to bankFunction
is actually a BankBalance. Marvelous! Note, using type synonyms would not have helped us here, because the underlying types would continue to be floats.
Summary
- The
type
keyword allows us to create type synonyms. - It can allow renaming of complex types without altering compiler behavior.
- The
newtype
keyword allows us to create a wrapper around a single type. - This makes it easier for us to distinguish (at compile time) between variables which have the same underlying type, but different meanings in our code.
Now that you’re ready to use types and newtypes to make your Haskell awesome, time to start making a Haskell project! Check out this checklist to make sure you have all the tools to get going!