Introducing Ergonomic Haskell
We have heard of Boring Haskell, Simple Haskell, and Fancy Haskell… but let’s ignore all of those and focus more on us. Why not make Haskell feel nicer to use? To do that we’ll focus on its UX and ergonomics and hopefully end up with…
Ergonomic Haskell!
If it is:
- Nice to use
- Not horribly complex
- Doesn’t give horrible type errors
So even if something costs us a little bit of extra complexity, the logic goes that the annoyance it gets rid of will be worth it most of the time. Extra points if it could let us tap into yet to be understood benefits.
There is a bias towards:
- ergonomics of reading over ergonomics of writing
- easy to read long term vs easy to understand in the very short term
NOTE: Everything about this series will be highly opinionated and subjective, based on things that I’ve noticed “feel good” and make code easier to understand over time.
The format will be:
- simple
- short
- avoid unnecessary detail or justification of choices
In other words, the ergonomics and end result are at the forefront :)
Motivating better ergonomics with Records: Why do this?
The default ergonomics are bad
This style is currently dominant with the simple but annoying work-around of prefixing the field name. Perhaps its harsh to call this bad ergonomics, but my brain really doesn’t take well to all the duplication and I’d hazard a guess this holds true for others as well.
Its usage is even noisier and less ergonomic in surprisingly consequential ways once your real-world code uses enough record fields. My theory is the duplication in label names puts a wrench in some peoples thinking process just like it does mine.
These are the data types for the default “bad ergonomics” example:
data Person = Person { name :: String, age :: Int}
data Dog = Dog {name :: String, age :: Int}
data CatBall = CatBall { name :: String, type_ :: String}
data Cat = Cat { name :: String, age :: Int, favoriteBall :: CatBall}
Here is what the implementation of that default looks like:
if personName person == "codygman"
then do
putStrLn $ "Good day, " ++ personName person
putStrLn $ "Your dog's name: " ++ dogName dog
putStrLn $ "Your cat's name: " ++ catName cat
if (catBallName . favoriteBall $ cat) == "doggos"
then do
putStrLn "One second.. there's been a mix up it seems!"
let fixedCat = cat { favoriteBall = (favoriteBall cat)
= "cattos", catBallType = "yarn ball" }
{ catBallName
}putStrLn $ "Your cat's favorite ball type: " ++ (catBallType . favoriteBall $ fixedCat)
else putStrLn $ "Your cat's name: " ++ catName cat
else putStrLn "THIS IS MY PROGRAM! I DON'T KNOW YOU! *KICK*"
In Ergonomic Haskell we don’t have room for all of this noise, but we do have room for one-time upfront costs that will make our lives a little nicer through the life of the project.
We instead demand the most ergonomic solution today! That looks like this:
if person ^. #name == "codygman"
then do
putStrLn $ "Good day, " ++ person ^. #name
putStrLn $ "Your dog's name: " ++ dog ^. #name
putStrLn $ "Your cat's name: " ++ cat ^. #name
if #cat ^. #favoriteBall % #name == "doggos"
then do
putStrLn "One second.. there's been a mix up it seems!"
let fixedCat =
& #favoriteBall % #name .~ "cattos"
cat & #favoriteBall % #ballType .~ "yarn ball"
putStrLn $ "Your cat's favorite ball type: " ++ fixedCat ^. #name
else putStrLn $ "Your cat's name: " ++ cat ^. #name
else putStrLn "THIS IS MY PROGRAM! I DON'T KNOW YOU! *KICK*"
Learned Simplicity
You might disagree with me on the above being simpler. At first I thought the same and asked:
How can symbols be simpler than words?
Answer: When they don’t matter.
What if you in oop languages for foo.bar
you instead had to do foo dot bar
each time? Wouldn’t that be distracting? By using something that takes up less space, you can concentrate on the piece that matters.
Less is more.
Recall that earlier I said one of the goals of Ergonomic Haskell was:
easy to read long term vs easy to understand in the very short term
My argument is that while this isn’t as simple as I’d like it to be, this is the most ergonomic and simple solution in Modern Haskell right now after a small adjustment period.
It’s simpler to just prefix field names with the data type name in the same way it’s simpler to not have Generics in a modern programming language: It’s not.
We have so many things to focus on that we must preserve our ability to communicate what pieces of our code are most important. In this case, it takes the form of us learning a few symbols in exchange for much less noisy code which more clearly communicates it’s intent over the long term.
Uncovering the simplicity
In other languages you might simply write dog.name
and cat.name
respectively. All Haskeller’s admit this is the simplest outcome and work is being done to make that possible in Haskell too (see the next section). No arguments here!
Here’s a table that should help make sense of the code above:
Language | Operation | Operator |
---|---|---|
Java | get | person.age |
Haskell | get | person ^. age |
Java | set | person.age = 25 |
Haskell | set | person & age .~ 25 |
Java | chained set | person.age = 25; person.name = “Bob” |
Haskell | chained set | person & age .~ 25 & name .~ “Bob” |
After a few coding sessions with these operators or playing around in the REPL, you might not even need this chart anymore.
I’d written a longer explanation of these things, but I think that’s where a lot of tutorials fall down. Our brains are very good at matching patterns and discerning meanings, so we should merely present differences in a simple format and let our readers brains go to work!
A potential bonus is that the underneath what we are using as getters/setters are fully powered optics. The value of that is hard to communicate, and it’s the “feeling” of using them that’s more valuable anyway. At the very end of this post I’ll share one tiny snippet to try and push that feeling from my brain to yours.
The future is hopeful
In the future per RecordDotSyntax the dot syntax you know from other languages will be possible and look like:
if person.name == "codygman"
then do
putStrLn $ "Good day, " ++ person.name
putStrLn $ "Your dog's name: " ++ dog.name
putStrLn $ "Your cat's name: " ++ cat.name
if cat.favoriteBall.name == "doggos"
then do
putStrLn "One second.. there's been a mix up it seems!"
let fixedCat =
.name = "cattos", favoriteBall.ballType = "yarn ball" }
cat { favoriteBallputStrLn $ "Your cat's favorite ball type: " ++ fixedCat.name
else putStrLn $ "Your cat's name: " ++ cat.name
else putStrLn "THIS IS MY PROGRAM! I DON'T KNOW YOU! *KICK*"
Hopefully we can simplify and get there soon but that’s enough about the future… we want the most ergonomic Haskell we can get NOW!
How to do it
- Install
optics
and enable these language extensions in your cabal file or package.yamldefault-extensions
or somewhere else you don’t have to worry about them again. A simple stack hpack example looks like:
executables:
records-exe:
main: Main.hs
source-dirs: app
default-extensions:
- DataKinds
- DuplicateRecordFields
- FlexibleContexts
- FlexibleInstances
- GADTs
- MultiParamTypeClasses
- OverloadedLabels
- TemplateHaskell
- UndecidableInstances
dependencies:
- records
- optics
- Use
optics-th
to generate optics labels for each field with thenoPrefixFieldLabels
option.
That means your data types will now look like:
data Person = Person {name :: String, age :: Int}
'Person makeFieldLabelsWith noPrefixFieldLabels '
- Start writing more ergonomic Haskell with better records now!
Here’s our end result, more Ergonomic Haskell with records:
if person ^. #name == "codygman"
then do
putStrLn $ "Good day, " ++ person ^. #name
putStrLn $ "Your dog's name: " ++ dog ^. #name
putStrLn $ "Your cat's name: " ++ cat ^. #name
if cat ^. favoriteBall % name == "doggos"
then do
putStrLn "One second.. there's been a mix up it seems!"
let fixedCat =
& #favoriteBall % #name .~ "cattos"
cat & #favoriteBall % #ballType .~ "yarn ball"
putStrLn $ "Your cat's favorite ball type: " ++ fixedCat ^. #name
else putStrLn $ "Your cat's name: " ++ cat ^. #name
else putStrLn "THIS IS MY PROGRAM! I DON'T KNOW YOU! *KICK*"
Runnable source code
You can also clone the repo here and navigate to the records directory.
The full formatted code
module Main where
import Lib
import Optics
import Optics.TH
data Person = Person { name :: String, age :: Int}
'Person
makeFieldLabelsWith noPrefixFieldLabels '
data Dog = Dog {name :: String, age :: Int}
'Dog
makeFieldLabelsWith noPrefixFieldLabels '
data CatBall = CatBall { name :: String, type_ :: String}
'CatBall
makeFieldLabelsWith noPrefixFieldLabels '
data Cat = Cat { name :: String, age :: Int, favoriteBall :: CatBall}
'Cat
makeFieldLabelsWith noPrefixFieldLabels '
main :: IO ()
=
main let person = Person "codygman" 1000
= Dog "doggo" 5
dog = Cat "doggo" 5 (CatBall "doggos" "tennis ball")
cat in do
if person ^. #name == "codygman"
then do
putStrLn $ "Good day, " ++ person ^. #name
putStrLn $ "Your dog's name: " ++ dog ^. #name
putStrLn $ "Your cat's name: " ++ cat ^. #name
if cat ^. #favoriteBall % #name == "doggos"
then do
putStrLn "One second.. there's been a mix up it seems!"
let fixedCat =
& #favoriteBall % #name .~ "cattos"
cat & #favoriteBall % #type_ .~ "yarn ball"
putStrLn $ "Your cat's favorite ball type: " ++ fixedCat ^. #name
else putStrLn $ "Your cat's name: " ++ cat ^. #name
else pure ()
Bonus Snippet
Welcome brave traveler!
First the data will look like this using javascript syntax.
let codygman = { name: "codygman",
pets: [ null,
name: "doggo", age: 1},
{name: "catto", age: 3}
{
] }
The javascript code you might want to write throws an error:
.pets.forEach(pet => pet.age += 1)
codygman// result: TypeError: pet is null debugger eval code:1:30
Here’s the equivalent Haskell that doesn’t throw an error:
& pets % mapped % mapped % age %~ (+1)
codygman -- result: Person "codygman" [Nothing, Just (Pet "doggo" 2), Just (Pet "catto" 4)]
Here is the bonus source code for you to play around with if you’re curious!