langston-barrrett.github.io
Haskell Style Guide
This is my personal style guide for writing Haskell code. As with all style guides, this guide is incomplete, fallible, and its recommendations aren't appropriate for every circumstance.
I've only recently started writing this, so it's obviously particularly incomplete. Hopefully, it will grow as I learn and think more!
This guide is focused only on recommendations that aren't universal across programming languages, for such recommendations see my Programming Style Guide. Additionally, I don't currently publish any recommendations that concern third-party libraries.
Its intended audience is intermediate or advanced Haskell programmers who endeavor especially to write reliable, correct code. In particular, I'm often willing to err on the side of reliability over "simple" code, e.g., by using GADTs. But to be honest, I mostly publish this so I have somewhere with thorough explanations that I can point people to when I make comments during code reviews.
The recommendations in this guide are split into three levels, based on the author's perception of their relative importance and applicability:
- Strongly Recommended: These recommendations are broadly applicable and have a high value-to-weight ratio.
- Recommended: These recommendations may apply less universally, or be less than essential to a strong style guide.
- Nice to Have: These recommendations are nice but not essential.
This document aspires to confirm to my Meta Style Guide.
Do you have ideas for this guide? Contact me!
Strongly Recommended
Recommended
Create Abstract Newtypes
Create many newtypes and hide their constructors. Where applicable, use the "smart constructor pattern".
-
Example
Instead of
module Main where createUser :: -- | First name of user. Must not contain whitespace. Text -> Maybe User createUser nm = if textHasWhitespace nm then Nothing else Just (User nm)
write
module FirstName (FirstName, makeFirstName, getFirstName) where -- | First name, e.g., of a user. -- -- Invariant: Does not contain whitespace. Constructor not exported to preserve -- invariant. newtype FirstName = FirstName { _getFirstName :: Text } deriving (Eq, Ord) makeFirstName :: Text -> Maybe FirstName makeFirstName nm = if textHasWhitespace nm then Nothing else Just (FirstName nm) -- Note: don't export _getFirstName directly, as record selectors can break the -- abstraction barrier we're establishing by not exporting the constructor. getFirstName :: FirstName -> Text getFirstName nm = _getFirstName
module Main where import FirstName (FirstName) createUser :: -- | First name of user. FirstName -> User createUser nm = User nm -- no need to check for whitespace!
-
Justification
A
newtype
provides semantic clarity---the name often hints at what the programmer intended to represent, rather than how they intended to represent it. In the example above, theFirstName
newtype essentially obviates the comment on the parameter ofcreateUser
---it's already clear from the type what the parameter represents.A
newtype
with a hidden constructor and an exported "smart" constructor can maintain data structure invariants. Because these invariants are local to the module in which the newtype is defined, they're relatively easy to check. In the example above, it's easy to see that it's impossible to construct aFirstName
containing whitespace outside of the module in whichFirstName
is defined. These invariants can also help avoid unnecessary partiality in other parts of the codebase, like howcreateUser
no longer needs to check for whitespace, and so no longer needs to return aMaybe
.The downside to this technique is the need to create additional modules and documentation. This amount of effort may not be appropriate in all circumstances.
-
See Also
https://haskell-at-work.com/episodes/2018-02-26-validation-with-smart-constructors.html
Write Explicit Export Lists
Every module should have an explicit export list.
-
Example
Instead of
module Foo where
write
module Foo ( Type1(field1) , function1 ) where
-
Justification
Using an explicit export list, a module may choose to not export certain functions. This leaves the module free to change their implementations or remove them entirely, with no concern that other modules (possibly even in other packages) might depend on them. Similarly, if a type's constructor is not exported, the representation of that type may be freely changed.
A module with an explicit export list also has a clear API: it consists of the functions and types that have been exported. This makes it easier to reuse the functionality in the module.
Use toInteger
Use toInteger
instead of fromIntegral
and
toEnum
when possible.
-
Example
Instead of
fromIntegral (0 :: Int) :: Integer
or
toEnum (0 :: Int) :: Integer
write
toInteger (0 :: Int)
-
Justification
fromIntegral
andtoEnum
can throw exceptions:ghci> (fromIntegral :: Integer -> Natural) (-1) Exception: arithmetic underflow ghci> toEnum ((-1) :: Int) :: Natural Exception: toEnum: unexpected negative Int ghci> (toEnum :: Int -> Word) (-1) Exception: Enum.toEnum{Word}: tag (-1) is outside of bounds (0,18446744073709551615)
fromIntegral
can also cause surprising over- and under-flow:ghci> (fromIntegral :: Integer -> Word) (-1) 18446744073709551615
Finally, it is generally more clear to use less-polymorphic functions.
Nice to Have
Format to Minimize Diffs
Try to format code in a way that minimizes diffs. While this recommendation applies beyond Haskell, the Examples below include Haskell-specific tips.
-
Example
Instead of
import LongModuleName (foo) import ShortName (quux)
write
import LongModuleName (foo) import ShortName (quux)
because if you import
ReallyLongModuleName
in a later comment, with the first formatting you have to fix the whitespace trailingLongModuleName
andShortModuleName
.Use
{}
in patterns when you know you don't care about any fields of a constructor. Instead ofcase foo of Bar _ _ -> "bar" Quux _ -> "quux"
write
case foo of Bar {} -> "bar" Quux {} -> "quux"
because you don't need to change these patterns if you add a field to
Bar
orQuux
. -
Justification
Minimizing diffs is helpful for focused code review, and when reviewing git history to understand a regression, see Programming Style Guide.
Minimal Polymorphism
Use less-polymorphic variants of functions where possible.
-
Example
Instead of
fmap (+1) [1, 2, 3]
write
map (+1) [1, 2, 3]
-
Justification
The use of a polymorphic function sends a signal to the reader that the polymorphism was necessary. This is confusing when the polymorphism was, in fact, unnecessary. Furthermore, a less polymorphic function provides more information to the reader about the types of its arguments: in the expression
fmap f x
, the type ofx
could be anyFunctor
, whereas inData.Set.map f x
,x
must have typeSet a
for somea
.