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.hswill produce an executable- hello, which you can run using- ./hello.
- runghc hello.hswill 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:
- ghcis like- rustc, while- cabalis like- cargo.
- ghcis like- javac, while- cabalis like- mvn.
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.0We 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 nameof the project matches the file name of the.cabalfile. This naming is a convention, but is not required.
- Notice that the versionstring has four sections instead of three. This is because.cabalfiles (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.patchrather thanmajor.minor.patch.
tested-with:   GHC ==9.0.2This 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-constraintsBesides 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.SomeLibraryThis 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: langimports the fields defined in common stanza named- langdefined above into this section.
- hs-source-dirs: srctells Cabal to look in the- srcfolder (relative to this- .cabalfile) 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 the- srcfolder.- For librarysections, I usually usesrcas 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 srcis a comment used as a formatting directive by- cabal-fmt, which is a really convenient autoformatting tool for- .cabalfiles. This particular directive automatically adds all Haskell modules within a folder to an- exposed-moduleslist.
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-projectThis 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: langimports- langlike how it was imported in the- librarysection.
- hs-source-dirs: cmd/hellodefines the root directory that modules in this section are located in.- For executables, I like to steal the Go convention of using cmd/FOOfor 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.hsdefines the main module for this binary. The file path to this module is relative to the- hs-source-dirof the section. Each executable must have exactly one main module, which is a module named- Mainthat exports a value named- mainof type- IO (). The entrypoint of the binary is evaluating- main.- We'll talk about the execution model later when we start talking about the language.
- It's convention to name this file Main.hssince 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-modulesbehaves like- exposed-modulesin- librarysections. It defines a list of other Haskell modules within this section's- hs-source-dirsthat are visible to the- Mainmodule. Usually, this is used for refactoring more complicated binaries into separate files.- Like in exposed-modules, we usecabal-fmthere to automatically populate this list.
 
- Like in 
- build-depends: hello-world-projectdefines a list of- librarydependencies that this component depends on. In this case, we're declaring that this component depends on exactly one library named- hello-world-projectat any version. This is actually the- libraryprovided 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 testFinally, 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.0indicates the type of this test. You almost always want this value to be- exitcode-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.4shows an example of loading an external dependency from Hackage. In this case, we're loading the- hspecpackage (which is useful for writing tests) at the latest version within the version spec- ^>=2.9.4that'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:
- Mainin section- executable hello.
- HFWP.SomeLibraryin section- library.
- Mainin section- test-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 Bis visible toA(e.g.Bis in the same component asA, orBis in a component or package that is abuild-dependsforA). You'll want to configure this in your.cabalfile.
- 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 of- COMPONENT, and shoves all of its build state into- ./dist-newstylelocally. You'll want to- .gitignorethis folder.
- cabal run [EXECUTABLE_COMPONENT]incrementally rebuilds- EXECUTABLE_COMPONENTand then executes it.
- cabal list-bin EXECUTABLE_COMPONENToutputs the location of a built binary.
- cabal test [TEST_COMPONENT]incrementally rebuilds- TEST_COMPONENTand 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