Lesson 24 / 25

Idiomatic Haskell and Common Pitfalls

Write clear Haskell and avoid frequent mistakes.

Writing Haskell others can read

Idiomatic Haskell writes type signatures for all top-level definitions, models the domain with ADTs and newtypes, keeps IO at the edges and logic pure, prefers total functions (handling every input) to partial ones, uses Maybe/Either for expected failures, and chooses names and small functions over clever one-liners. Common pitfalls: partial functions (head, tail, fromJust, read, !! and maximum on empty lists); String for large text; lazy foldl and lazy accumulators causing space leaks; nub on large lists; excessive point-free style that hides meaning; orphan instances; missing version bounds on dependencies; overusing type-level features; confusing return with an early exit (in Haskell return just wraps a value; it does not stop a do block); show used for user-facing output or serialisation; and ignoring warnings. Enable -Wall (and consider -Wincomplete-uni-patterns and -Wpartial-fields), run HLint, and lean on the type checker: when refactoring, change a type and follow the compiler errors until everything compiles again.

Partial versus total code

The same task written unsafely and safely.

import Data.List (sortOn)
import qualified Data.List.NonEmpty as NE
import Data.List.NonEmpty (NonEmpty (..))
import Text.Read (readMaybe)

-- partial: crashes on empty input or a non-numeric price
cheapestBad :: [(String, String)] -> String
cheapestBad items = fst (head (sortOn (\(_, p) -> read p :: Int) items))

-- total: the type says what can go wrong, and every case is handled
data PriceError = NoItems | BadPrice String
  deriving Show

cheapest :: [(String, String)] -> Either PriceError String
cheapest items = do
  parsed <- traverse parse items
  case NE.nonEmpty parsed of
    Nothing -> Left NoItems
    Just ne -> Right (fst (NE.head (NE.sortWith snd ne)))
  where
    parse (sku, raw) = maybe (Left (BadPrice raw)) (\p -> Right (sku, p :: Int)) (readMaybe raw)

-- 'return' does not exit early: this always prints "done"
example :: IO ()
example = do
  _ <- return ()
  putStrLn "done"

main :: IO ()
main = do
  print (cheapest [("pen", "4950"), ("ink", "1990")])   -- Right "ink"
  print (cheapest [])                                    -- Left NoItems
  print (cheapest [("pen", "abc")])                      -- Left (BadPrice "abc")
  print (NE.head (3 :| [1, 2 :: Int]))                   -- 3: NonEmpty makes head total

Let the compiler drive refactoring

In Haskell, changing a type is often the best first step of a refactor: GHC lists every place that needs updating, and when the code compiles again, most of the work is done.

Quick check: In a Haskell do block, what does `return x` do?

  • Wraps x in the monad; the following lines still run
  • Exits the function immediately like in C
  • Throws an exception
  • Prints x
Answer

Wraps x in the monad; the following lines still run — return (like pure) only injects a value into the monad.