Learning Haskell
TLDR: In this blog post, I list some of the challenges that I faced while learning Haskell. Skip down to Challenges to see my list.
My journey to Haskell dates back to the start of my university days. I first heard about Haskell from an older grad student I worked with. He was an opinionated man with an impressively long beard who rolled his own cigarettes and often took smoke breaks just outside the department building. Haskell was a challenging and mind-bending language, he promised, as a puff of smoke wafted into the air.
As a budding, wide-eyed computer science major, I couldn’t help but be curious. And sure enough, Haskell was different from any language I had ever used (which up to that point, was mostly Java). Despite my best efforts to learn it, I quickly found myself stuck.
Over the years, I occasionally would run across a new blogpost claiming to explain monads (or GADTs or type classes or, well, you get the idea) and decide to give Haskell another shot. Invariably, I would stumble over some tricky bit of code and retreat. But for whatever reason, I wasn’t willing to throw in the towel (whether it was curiosity, pride, or stubbornness, I can’t be sure).
In the past few weeks, I found my way back to Haskell again by way of a discussion with work colleagues. A smattering of further insightful conversations, cryptic GHCi sessions, and nightly reading stints followed. Now, after a decade and a half of false starts, snagging a PhD in the meantime, I finally feel that I am beginning to understand Haskell1.
In this blog post, I want to:
- reflect on some of the challenges that I faced on my learning journey;
- document what resources/methods I found helpful, if any;
- present these thoughts to others who might be learning or teaching Haskell.
Challenges
- Mindset
- Functors, Applicatives, and Monads… Oh My!
- Terseness
- Show Me
- Parallel Worlds
- Higher-Kinded Types
- Extensive Extensions
- Build Tools
Mindset
While mindset is clearly subjective (and may evoke images of fake gurus shilling poorly-conceived get-rick-quick courses), I do think this idea is worth recalling briefly. In my early experience from reading blogposts and talking to others, Haskell was always shrouded in a certain sense of mystery, revered as a powerful language beyond the reach of mortal programmers (or at least, as a hard-to-learn language). Both the abstraction and terseness issues that I mention below seem to contribute to this reputation.
Of course, in actuality, Haskell is a tool for programming with its own quirks and benefits, just like any other language.
But, by adopting the former mindset instead of the latter, I became intimidated and more likely to give up.
Recommendation: Keep things in perspective.
- For programmers who started with an imperative background (like me), Haskell will be different and probably challenging to learn.
- At the same time, it’s just a programming language with a different computational model than imperative languages; it is not magic or beyond the reach of normal programmers.
Functors, Applicatives, and Monads… Oh My!
Haskell has its theoretical roots in type theory, which has close connections to category theory, with many concepts also imported from abstract algebra. This theoretical legacy shines through strongly in the interface design in Haskell type class hierarchy. In my opinion, much of the mystery surrounding Haskell stems from these interfaces. When the average person sees the words functor, semigroup, monoid, or monad, they probably feel either mystified or intimidated. I know I felt both when I first started my Haskell journey.
Recommendation: This is a tough one. On the one hand, I think all of above algebraic/categorical structures are really useful and good abstractions! On the other hand, learning all of them, even intuitively, is a fair amount of work. There are a couple things to do if this feels overwhelming:
- find a good resource that takes you through the concepts step-by-step;
- forget about all of the mathematical/historical baggage and learn the type classes just as black box interfaces that satisfy certain rules;
- avoid wikis and other reference sites; their use of technical language and lack of linear structure can trip up beginners.
As for general resources, I think you can’t go wrong with the incredibly gentle and thorough Haskell Programming from First Principles; just be aware that it is a hefty tome weighing in at about 1200 pages!
Terseness
I argue that Haskell is a terse language in two senses:
- syntactically, i.e., concepts can be expressed with a minimum of syntactic ceremony2; and
- semantically, i.e., certain omitted details can be automatically inferred by the compiler.
Together, these imply you can express powerful concepts with a small amount of code. The challenge is also two-fold:
- syntactically terse languages provide less syntactic pointers that describe the meaning of the code;
- semantically terse languages may require the programmer to explicitly reconstruct details that are only implicitly described in the code.
Both of these items empower experts who are already fluent in a language but can impede beginners who wish to learn it (though not always).
Recommendation: There are two main recommendations here:
-
Use text editors with syntax highlighting to alleviate potential syntactic confusion;
-
Learn and lean on interactive debugging tools like GHCi whenever possible that can:
- show all reconstructed details that were omitted from the codebase;
- step through the process taken to arrive at some conclusion.
Show Me
As a beginner, when you open up GHCi and try to get help for some common function, say foldl
, you may naturally type:
ghci> foldl
error:
• No instance for (Show ((b0 -> a0 -> b0) -> b0 -> [a0] -> b0)) arising from a use of ‘print’
• In a stmt of an interactive GHCi command: print it
Unfortunately, this error message is not very helpful for a beginner.
Recommendation:
Use the GHCi :type
or :info
commands.
I am a bit surprised the interpreter does not directly provide this hint; it would save all beginners at least one internet search.
Parallel Worlds
There are two totally separate, almost parallel worlds in Haskell: the world of terms and the world of types. However, sometimes there are parallel entities which exist in both worlds which have the same name. As an example, consider this definition:
data Bar a = Bar a
-- ----- -----
-- Type Term
On the left hand side of the equals sign, we are defining a type constructor Bar
parameterized over a type variable a
.
Conversely, on the right hand side of the equals sign, we are defining a term constructor Bar
parameterized over a term variable a
.
This means we can write things like:
{- Types -} intBar :: Int -> Bar Int
{- Terms -} intBar x = Bar x
Experienced Haskellers know that:
- the first line is a type signature and makes a statement about the world of types;
- the second line is a function definition and makes a statement about the world of terms (though type names can still creep if types cannot be inferred).
Thus, experienced Haskellers know that Bar Int
in the first line must refer to a type constructor and Bar x
on second line refers to a term constructor.
However, for a beginner, this overlap can be confusing.
Recommendation: N/A
Even More Parallel Worlds
You might think that two parallel meanings of a word was enough… but Haskell doesn’t mess around.
For extra credit, we can enable the -XDataKinds
extension.
With this extension, the compiler generates a third parallel world of kinds which can be used in type-level programming.
With -XDataKinds
enabled, upon seeing a data declaration, (in most cases) the compiler will implicitly generate a new kind with new types to inhabit it.
In the book Thinking With Types, the author Sandy Maguire invents a kind declaration notation to describe the way this is done.
As an example, recall the Bar
declaration above.
When the compiler sees this type declaration:
data Bar a = Bar a
-- ----- -----
-- Type Term
It will implicitly generate a kind declaration (not valid Haskell syntax):
kind Bar a = 'Bar a
-- ----- ------
-- Kind Type -- world
-- (Type) (Term) -- world promoted from
where the left and right sides of the kind declaration are promoted from the left and right hand sides of the type declaration, respectively.
Thus, in our final tally, Bar
(possibly with a leading single quote) now has four possible usages:
Bar
- a term constructorBar
- a type constructor'Bar
- a type constructor (i.e. a promoted term constructor)Bar
- a kind (i.e. a promoted type constructor)
Fortunately, the -XDataKinds
extension is only needed for type-level programming.
Most of the time, Haskellers can safely ignore this.
Recommendation:
Be aware of the -XDataKinds
extension and its associated syntax.
That way, when you see this syntax in other code, you will won’t be confused (it’s hard to search for syntax online!).
If you want to dive deeper into this subject, I can recommend Sandy’s book Thinking With Types.
Higher-Kinded Types
Higher-kinded types (HKTs) offer a lot of power, i.e., type constructors which take types as arguments. If you are unfamiliar with this concept, think of types like:
-
[] :: Type -> Type
Comment: takes typea
to lists of typea
Example:[String]
- type of lists of strings -
Maybe :: Type -> Type
Comment: takes typea
to optionala
Example:Maybe Int
- type of optional integers -
(->) :: Type -> Type -> Type
Comment: takes typesa
andb
to functions froma
tob
Example:Int -> Bool
- type of functions fromInt
toBool
These are only first-order higher-kinded types. We also have examples of second-order types. Most commonly, these are types defined implicitly by type classes. Examples include3:
Functor :: (Type -> Type) -> Type
Monad :: (Type -> Type) -> Type
The fact that types not only act as functions at the type level—but as higher-order functions—was challenging. As we’ll see in the next section, partial application of HKTs takes the challenge up a notch.
Recommendation: Learn about higher-order functions (you’ll want to do this anyway). Make a mental note that types in Haskell are functions at the type-level.
Partially Applied HKTs in Type Class Instances
Oftentimes, we want to create a type class instance for a type whose kind doesn’t quite match the type class.
As an example, recall the arrow type (->)
which takes two type arguments.
We would like to apply the Functor
type constructor to arrow type, but we cannot, because the kind signatures don’t match up.
Functor
needs an argument of kind Type -> Type
, but (->)
has kind Type -> Type -> Type
.
To solve this problem, we can partially apply type constructors.
Many examples of partial application of HKTs occur in the type class hierarchy including:
-
The
Functor
instance for arrow types:instance Functor ((->) r)
. Here we partially applied the arrow constructor with only one type. -
The
Monad
instance forStateT
used by the type synonymState
:instance Monad m => Monad (StateT s m)
. Here we partially applied theStateT
contructor with two types.
The trickiness of partially applied HKTs in type instances is that:
- the HKT argument in the type instance definition is partially applied but;
- the missing type(s) are specified in the type class definition.
A naive Haskeller may not even realize that they are working with a partially applied HKT. This partial application is only apparent if one has previously seen the type constructor or type class definition in question.
Recommendation: Learn about currying (you’ll want to do this anyway). Amend the mental note you just made to state that type constructors in Haskell are actually curried functions at the type-level and can be partially applied.
Extensive Extensions
All popular programming languages evolve into new versions over time. When this happens, a developer has to choose if and how to do version migration. However, for some languages (like Haskell), the choice is more nuanced.
In addition to versions, Haskell has a sizeable collection of extensions, i.e., language-level features that can radically change the meaning of programs. These extensions can add, remove, or reinterpret existing syntactic constructs and even modify core compiler behaviors.
This leads to several issues:
- The proliferation of extensions can lead to confusion about what syntax is accepted and what code compiles in what context.
- Each extension must be learned additionally on top of the core Haskell specification and standard library. For a language as complex as Haskell, that’s a big ask.
Fortunately, there have been efforts to curb or mitigate the extension explosion. Newer versions of GHC (the Haskell compiler) enable popular extensions by default (a way of standardizing the extension set) and often emit warnings when code requires a specific extension.
Recommendation: Don’t try to learn all of the extensions; it’s tons of work and many of them are deprecated. Do consult the official extension list in the GHC user guide when needed.
Build Tools
Build tools (and package formats) are all about gathering dependencies and build recipes for your code so that someone else can build it on their machine without your help. Currently, the Haskell community is currently split between two seemingly good similar-but-different build tools: Cabal (2004) and Stack (2015).
When I first started learning Haskell, Cabal was the only option available (though at that time I never needed it). Later in my Haskell journey, I discovered Stack, which I used almost exclusively since then. While I find Stack generally works well, I also find it to be complex.
Unlike Python pip or the Node package manager, Stack seems to discourage interactive installation of packages.
Instead, users are directed to edit their stack.yaml
and let Stack install packaged dependencies and build the project as necessary.
When I first started, I was somewhat confused because the experience was different from what I had used previously.
So, the trade-off seems to be: a more complex setup process in exchange for a more documented and reproducible build.
Recommendation: Pick a build tool between the two options and stick with it. Carefully read the Stack or Cabal user guide.
Wrapping Up
If you’ve made it this far, thanks for following along!
If you’re a newcomer to Haskell, I hope you learned something from this post.
Haskell is an ambitious and fun language that allows you to write compact, fast, and safe code in a functional style. Partially due to these demanding goals, Haskell has a lot of moving parts. Mastering it takes time.
In parting, let me close with one last tip.
Recommendation: Use the right tool for the right job.
-
I guess the old adage that you need a PhD to learn Haskell turned out to be true… at least in my case. ↩︎
-
The fact that a language has a terse syntax does not imply that it has a simple syntax. Clearly, Haskell has fairly complex syntax with its rules about indentation and bracketing and a plethora of extensions that give you the ability to write the same program in many different ways (e.g. ADT vs GADT syntax, guards/where clauses vs. if/pattern match, etc). Arguably, array-like languages like APL represent the extreme of terse and simple syntaxes. ↩︎
-
Technically, the constructors which are implicitly defined for type classes inhabit the
Constraint
kind instead of theType
kind—but that is more of an implementation detail. ↩︎