Ergonomic Haskell 1 - Records

Posted on September 7, 2020

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) 
                                     { catBallName = "cattos", catBallType = "yarn ball" } 
                                 }
              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 =
              cat & #favoriteBall % #name .~ "cattos"
                  & #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 =
              cat { favoriteBall.name = "cattos", 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*"

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

  1. Install optics and enable these language extensions in your cabal file or package.yaml default-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
  1. Use optics-th to generate optics labels for each field with the noPrefixFieldLabels option.

That means your data types will now look like:

data Person = Person {name :: String, age :: Int}
makeFieldLabelsWith noPrefixFieldLabels ''Person
  1. 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 =
              cat & #favoriteBall % #name .~ "cattos"
                  & #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}

makeFieldLabelsWith noPrefixFieldLabels ''Person

data Dog = Dog {name :: String, age :: Int}

makeFieldLabelsWith noPrefixFieldLabels ''Dog

data CatBall = CatBall { name :: String, type_ :: String}

makeFieldLabelsWith noPrefixFieldLabels ''CatBall

data Cat = Cat { name :: String, age :: Int, favoriteBall :: CatBall}

makeFieldLabelsWith noPrefixFieldLabels ''Cat

main :: IO ()
main =
  let person = Person "codygman" 1000
      dog = Dog "doggo" 5
      cat = Cat "doggo" 5 (CatBall "doggos" "tennis ball")
   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 =
                      cat & #favoriteBall % #name .~ "cattos"
                          & #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:

codygman.pets.forEach(pet => pet.age += 1)
// result: TypeError: pet is null debugger eval code:1:30

Here’s the equivalent Haskell that doesn’t throw an error:

codygman & pets % mapped % mapped % age %~ (+1)
-- 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!