Haskell for Working Programmers is a guide for professional programmers for picking up Haskell. Most Haskell learning materials swing too hard in either direction regarding experience: they assume either an academic computer science background, or clean start from complete basics.
This guide is intended for folks working a working, professional knowledge of an existing popular programming language. We’ll skim through common concepts shared with other languages (e.g. "what is a string?"), and learn Haskell-specific concepts by comparing, contrasting, and drawing analogies to other more common languages.
We’ll start by getting your machine set up to build Haskell programs. Then, we’ll compile a program end-to-end and get a "Hello, World" program working. Afterwards, we’ll run through a crash course of Haskell programming concepts. Finally, we’ll put those concepts into practice by building some non-trivial real-world programs.
Use ghcup to install and manage versions of GHC, Cabal, Stack, and HLS. GHC is the main Haskell compiler, and Cabal is the main build tool. If you’re familiar with Node.js, some analogies here are:
- GHC ~= Node. It’s not the only Haskell compiler (much like how Node isn’t the only standalone JS runtime), but it’s the one everyone uses.
- Cabal ~= NPM. It’s the build tool most people use, and is the official one.
- Stack ~= Yarn. It’s an alternative build tool to Cabal, and it was a lot better back than Cabal back before Cabal natively supported sandboxes. It does mostly the same things, but used to handle dependencies better. Nowadays, don’t bother - you should prefer Cabal unless you know what you’re doing and deliberately need a Stack-specific feature.
HLS is the Haskell Language Server. You usually don’t need to install this standalone, because the most popular code editors’ Haskell plugins usually manage this for you.
You’ll want to install GHC and Cabal. GHCup will list recommended versions (the latest version that most of the ecosystem is compatible with - this is sometimes not the latest version when breaking changes to the standard library are made) to install.
Writing Haskell without good IDE support is a pretty annoying experience. You should use an officially supported editor:
- For VS Code, you’ll want to install and use the official Haskell plugin.
- For Emacs, you’ll want to install and use haskell-mode.
Other editors might have good Haskell support, but their plugins are not officially supported by the Haskell.org committee. In a pinch (if you can’t get any other plugins working properly), use ghcid, which is a simple and dumb daemon that just reloads ghci
(the Haskell REPL) when file changes are detected.
Expect a good editor plugin to give you:
- Type information of expressions on hover. This is the killer feature. It is extremely useful for debugging, and writing Haskell without this functionality is much more annoying.
- Type-driven autocomplete for holes.
- Inline compilation errors and warnings.
- Automatic symbol autocomplete and imports management.
- Symbol renaming.
Now that GHC is installed, let’s get started by compiling a working program. Don’t worry about understanding the code right now. Our current goal is to get familiar with the compiler as a tool.
Let’s start with this "Hello, World" program (see exercise 001-hello-world-script):
module Main (main) where
main :: IO ()
main = putStrLn "Hello, World!"
Save this file as hello.hs
. With a simple script like this, we have a couple options for execution:
ghc hello.hs
will produce an executablehello
, which you can run using./hello
.runghc hello.hs
will interpret this program.
We can also load the program into a REPL using ghci hello.hs
. Once loaded, evaluate main
to execute the program.
Writing quick scripts like this can be useful for small, one-off programs. However, most of the time you'll want to set up a properly built project using cabal
.
Why? Because ghc
is a compiler, not a build tool. Once you start building programs that bring in other modules (e.g. by importing dependencies), you'll need to manually configure ghc
's flags so it knows where to look for the code for those modules. This quickly becomes an annoying, tedious, and unmanageable mess.
cabal
handles invoking ghc
for us. All we need to do is set up a project in a structure that cabal
understands, and it will handle the rest. If you're familiar with other compiled languages, some analogies here are:
ghc
is likerustc
, whilecabal
is likecargo
.ghc
is likejavac
, whilecabal
is likemvn
.
Let's get "Hello, World" set up into a proper project. Take a look at exercise 002-hello-world-project. We're going to go through this project line-by-line.
If you want to jump directly into the code, feel free to skip this section and come back if you're confused. The most important thing we explain here is how the module system (imports, exports, and filesystem layout) works. But all you really need to know to start touching code is that cabal build
builds the project, cabal run hello
will run the executable in this exercise, and cabal test
will run the tests.
Let's start by examining hello-world-project.cabal
, which defines the Cabal project. You can find documentation for all of these fields in the cabal
docs.
cabal-version: 3.0
name: hello-world-project
version: 0.1.0.0
We start off with the usual front matter. The cabal-version
here is the version of the .cabal
file, not the version of the cabal
executable that you're using. The supported file versions of each executable are listed in the cabal
docs.
The name
and version
fields describe the project:
- Notice that the
name
of the project matches the file name of the.cabal
file. This naming is a convention, but is not required. - Notice that the
version
string has four sections instead of three. This is because.cabal
files (and the Haskell package ecosystem in general) use Haskell's Package Versioning Policy specification for versions, which is different from the commonly used SemVer specification. The main difference is that the sections aremajor.major.minor.patch
rather thanmajor.minor.patch
.
tested-with: GHC ==9.0.2
This field isn't commonly used, but I find it's a useful way to indicate what GHC version you're using. Unfortunately, cabal
does not check that you're actually using this version of ghc
.
common lang
build-depends: base >=4.12 && <4.16
default-language: Haskell2010
ghc-options:
-Wall -Wincomplete-uni-patterns -Wcompat
-Wincomplete-record-updates -Wmissing-home-modules
-Wmissing-export-lists -Wredundant-constraints
Besides top-level project settings, a .cabal
file defines a list of sections. Some of these sections define build targets (e.g. library
, executable
, test-suite
, benchmark
, etc.), which Cabal calls components.
This section is a common stanza named lang
, which lets you factor out common shared fields for other sections. In this one, we define some dependencies shared by every section, as well as some shared compiler options.
library
import: lang
hs-source-dirs: src
-- cabal-fmt: expand src
exposed-modules: HFWP.SomeLibrary
This section is a library section. Libraries contain the bulk of your code. If you decide to publish this project as a package on Hackage, the code that other users will be able to consume is the code contained in your library
section.
There are a couple of important fields in this section:
import: lang
imports the fields defined in common stanza namedlang
defined above into this section.hs-source-dirs: src
tells Cabal to look in thesrc
folder (relative to this.cabal
file) for Haskell modules that belong to this section. In particular, this means that the module names of modules in this section will be relative to thesrc
folder.- For
library
sections, I usually usesrc
as the source directory name. - We'll talk more about how modules work in a bit once we start looking at the Haskell files themselves.
- For
exposed-modules: ...
lists all the Haskell modules that this library exposes. Every Haskell file is its own module. Modules that are not explicitly listed in this field are not exposed, which means they aren't visible to other code (i.e. other sections or packages) that imports this library.cabal-fmt: expand src
is a comment used as a formatting directive bycabal-fmt
, which is a really convenient autoformatting tool for.cabal
files. This particular directive automatically adds all Haskell modules within a folder to anexposed-modules
list.
Note that you can also add names to library sections to create internal libraries. This is an advanced feature for specific weird use cases, and is probably not what you want.
executable hello
import: lang
hs-source-dirs: cmd/hello
main-is: Main.hs
-- cabal-fmt: expand cmd/hello -Main
other-modules:
build-depends: hello-world-project
This section is an executable section named hello
. Executable sections define the binaries that get produced when we cabal build
this project. Each binary has its own section, and the compiled binary will be named whatever its corresponding executable section is named. In this case, this section defines the entrypoint for a binary named hello
.
Code in executable sections should be a very thin wrapper over library code. For example, you might handle CLI flag parsing or other startup/shutdown logic here while importing the vast majority of your business logic from your library
.
In this section:
import: lang
importslang
like how it was imported in thelibrary
section.hs-source-dirs: cmd/hello
defines the root directory that modules in this section are located in.- For executables, I like to steal the Go convention of using
cmd/FOO
for programs namedFOO
. It's a useful way to keep binaries together while also giving them each their own file tree.
- For executables, I like to steal the Go convention of using
main-is: Main.hs
defines the main module for this binary. The file path to this module is relative to thehs-source-dir
of the section. Each executable must have exactly one main module, which is a module namedMain
that exports a value namedmain
of typeIO ()
. The entrypoint of the binary is evaluatingmain
.- We'll talk about the execution model later when we start talking about the language.
- It's convention to name this file
Main.hs
since it contains a module namedMain
, but that isn't strictly required. We'll talk about modules and file names in a bit when we start looking at the Haskell files themselves.
other-modules
behaves likeexposed-modules
inlibrary
sections. It defines a list of other Haskell modules within this section'shs-source-dirs
that are visible to theMain
module. Usually, this is used for refactoring more complicated binaries into separate files.- Like in
exposed-modules
, we usecabal-fmt
here to automatically populate this list.
- Like in
build-depends: hello-world-project
defines a list oflibrary
dependencies that this component depends on. In this case, we're declaring that this component depends on exactly one library namedhello-world-project
at any version. This is actually thelibrary
provided by our own project.- Depending on your own library without version constraints is the common way to make your library code visible to your other components.
- Note that you can also create executables that don't include your library code. You might rarely want to do this to reduce the binary size of one-off tools.
All executables can be compiled and run using cabal run COMPONENT
. For example, you can run this executable using cabal run hello
. If you have other components of different type that are also named hello
, you can use this component's fully-qualified component name with cabal run exe:hello
.
test-suite tests
import: lang
type: exitcode-stdio-1.0
hs-source-dirs: test
main-is: Main.hs
-- cabal-fmt: expand test -Main
other-modules:
build-depends: hspec ^>=2.9.4 -- TODO: also import library and add a test
Finally, this section is a test suite section named tests
.
Notice that this section is roughly the same as the executable
with two differences:
type: exitcode-stdio-1.0
indicates the type of this test. You almost always want this value to beexitcode-stdio-1.0
. In this mode, the test suite is treated as a special kind of executable. When it runs, it signals success by exiting 0 and failure otherwise.build-depends: hspec ^>=2.9.4
shows an example of loading an external dependency from Hackage. In this case, we're loading thehspec
package (which is useful for writing tests) at the latest version within the version spec^>=2.9.4
that's compatible with the rest of this component's build.
Now that we've seen how the project is laid out, let's look at how the actual individual Haskell modules interact.
Haskell's language specification (spec, tutorial) defines a notion of "modules", which act as namespaces of symbols. Module names can be any valid Haskell identifier that begins with a capital letter.
GHC (the compiler) implements modules by mapping every module to a file whose name matches the module name after replacing dots with directory separators (spec). For example, module A.B.C
should be defined in file A/B/C.hs
.
Cabal (the build tool) handles making sure that GHC's search paths are set correctly for each component in the Cabal project.
Our example project has three Haskell modules:
Main
in sectionexecutable hello
.HFWP.SomeLibrary
in sectionlibrary
.Main
in sectiontest-suite tests
.
Notice how the module's file locations match their module names.
To import a symbol s
into module A
from module B
:
- Make sure that
B
is visible toA
(e.g.B
is in the same component asA
, orB
is in a component or package that is abuild-depends
forA
). You'll want to configure this in your.cabal
file. - In
B
, make sure your module exportss
(e.g.module B (s) where
). - In
A
, imports
(e.g.import B (s)
).
Notice that module names can overlap between different components or packages! If you're trying to import a module whose name conflicts with an existing module, GHC and Cabal provide some tricks (e.g. PackageImports
pragma, mixins, see Stack Overflow) to disambiguate or rename modules.
Ideally, avoid naming your modules so that they can collide. For applications that I'm writing, I usually namespace my modules by application name.
In addition to modules, Cabal adds a notion of "packages", which are units of distribution of code on Hackage. Your .cabal
file defined a single package.
You can build-depends
on a package by name to tell Cabal to download that package and make the modules it contains visible to the component declaring the dependency.
If you're familiar with Node.js, a Cabal package is roughly an NPM package, and a Haskell module is roughly single Node.js file. Fun fact: individual files in Node are actually also their own separate modules (they are individual CommonJS modules)!
With your project set up properly, Cabal provides some useful commands:
cabal build [COMPONENT]
runs an incremental rebuild ofCOMPONENT
, and shoves all of its build state into./dist-newstyle
locally. You'll want to.gitignore
this folder.cabal run [EXECUTABLE_COMPONENT]
incrementally rebuildsEXECUTABLE_COMPONENT
and then executes it.cabal list-bin EXECUTABLE_COMPONENT
outputs the location of a built binary.cabal test [TEST_COMPONENT]
incrementally rebuildsTEST_COMPONENT
and runs the test suite.
Alright, now that we've properly set a project up and learned about how it's laid out, let's look at the actual program. We'll start by examining the syntax and semantics of our existing "Hello, World" program. Afterwards, we'll start adding to this program, implementing new features, and learning about new language features as we go.
We'll start by doing a line-by-line walkthrough of cmd/hello/Main.hs
in exercise 002-hello-world-project.
module Main (main) where
main :: IO ()
main = putStrLn "Hello, world!"
- main
- prelude
- values and signatures
- evaluation model