How I'm currently writing Haskell

Recently, I decided to jump back into learning and exploring in the fun and mind-bending world of functional programming. I've spent some time diving into Haskell before (in 2018 I completed all Advent of Code challenges in Haskell!) and I very much enjoyed myself. The amount of mental backflips I had to perform with it being such a different way of thinking about my code was valuable, and enjoyable!

I decided, this time around, to jump in even deeper.

To that end, I installed NixOS on my personal laptop and decided to learn more about the purely functional side of package management, environment management and operating systems. A good friend of mine had made me familiar with Nix before, and it piqued my interest.

My system and user setup can be found in my dotfiles repo, stored in a nixos branch. But for this article I want to talk about the development environment setup I have put together using Nix for the purpose of writing Haskell code.

Note: I'm sure there are a lot of things I'm missing here, and could greatly improve, and will probably learn about as I get further in. What I'm sharing today is something that at least works for me, for now.

Goals

As always, there are tons of ways a person can approach dev environments, and using diferent tools in different ways can lead to a huge variety of outcomes. To that end, I'm rooting my setup in the following goals.

Efficient access to information. When I want to look up the type signature or documentation for a function, that should be a relatively quick and easy process.

Fast feedback. Haskell development is all about having a conversation with the compiler, and is part of what makes it so much fun. Reducing the latency in that conversation means a more fluid and enjoyable development experience.

Locally contained. Since we're using Nix, it makes sense to have as much of the tooling and configuration contained within the project itself, so anyone with nix can just get up and running.

Simplicity. Should be self explanatory, but I don't want to introduce any sort of complexity, unless the value it adds is worth it and can't be achieve by another, simpler tooling combination.

As you'll see, there are tradeoffs made between each of these goals in certain decisions, but I try to make sure I understand and make those tradeoffs consciously.

Tools

The tools involved in this whole setup are as follows.

Nix + Cabal, for dependencies.

Vim, for writing the code.

GHCid, for compiler feedback.

Hoogle, for documentation.

Vim, Tmux, Fish, Git

There are a few tools that are installed at the system level, and not the project level. Having literally everything defined and available within the project is a cool idea, but all things must have a limit.

My preferred shell is fish, preferred editor is vim, preferred version control system is git, and I prefer managing my terminal sessions with tmux.

I also have some vim plugins installed, but have opted to try and keep them simple by avoiding any language server integration, etc. Syntax highlighting is the main thing I'm going for here, and I'll depend on self contained tools running in separate terminal sessions for the feedback I would otherwise want directly in my editor.

This decision has greatly simplified the overall setup. The headache of getting vim integration with local nix dependencies was not worth it.

How I have all of these things configured can be seen in my dotfiles repo.

Cabal, getting started

Cabal is the tool we will use to manage our Haskell project, and it's dependencies. It also has a command for initializing a new project, which we can use with the following nix-shell command.

$ nix-shell -p ghc --run "cabal init"

This will take you through some prompts to get your project configured, and will output some files, most important of which is the projectname.cabal file. This file defines the project, what it outputs, and what it's Haskell dependency inputs are.

Real quick, why not Stack?

Another option we have for defining our Haskell project and it's dependencies was to use Stack, which will almost always come up as the preferred tool when Googling around, except if you're also using Nix.

One of stack's main benefits is that it pulls project depdendencies from Stackage, a project which maintains snpashots of known stable Haskell packages that work well together, pass tests, and build consistently. This helps to avoid what is known in the Haskell world as "cabal hell", where trying to get a set of dependencies for your project that all build together and work as intended takes way more work than should be necessary.

So why don't we need Stack? Because Nix knows to pull it's Haskell packages from Stackage as well! This is accomplished using the cabal2nix tool. As we'll see in the next section, using this tool from a nix file directly is quite simple and convenient.

Base Nix setup

There are 2 nix files we will keep in our project root. One is for defining the project itself, mostly based on our cabal file. The second is for defining our local development environment tooling.

The first one is default.nix. This file is pretty simple, and just calls cabal2nix on our local cabal file that was created with our earlier cabal init. This is all it contains.

# default.nix
{ pkgs ? import <nixpkgs> {} }:
pkgs.haskellPackages.callCabal2nix "your-project-name" ./. {}

This would be the same as using the cabal2nix utility to generate a nix file from our project's cabal file. With this in place, we don't have to call cabal2nix any time our cabal file is updated.

The second one is shell.nix. This one is used for defining all the dependencies needed for our local development environment. It first pulls in everything from default.nix, and then adds a few more things used for the dev process.

# shell.nix
{ pkgs ? import <nixpkgs> {} }:
let
project = pkgs.haskellPackages.callPackage ./default.nix {};
in
pkgs.stdenv.mkDerivation {
name = "shell";
buildInputs = with pkgs; project.env.nativeBuildInputs ++ [
haskellPackages.hoogle
haskellPackages.hlint
haskellPackages.ghcid
];
}

The extra tools that are getting included are..

hoogle, for looking up information from Hoogle.

hlint, for linting.

ghcid, for having a conversation with the compiler (mentioned shortly).

With these files in place we can run nix-shell and be dropped into an environment where all of the above dependencies that we've specified are available, including the dependencies from our cabal file, pulled from stackage.

And that's it! As I mentioned above, vim, tmux, and fish are all installed at the user level, and some vim plugins are in place.

Next we'll talk about the two important pieces for making the development process effective, efficient, and enjoyable.

GHCi(d)

As we saw in our shell.nix file, we now have a couple different utilities installed, one of which is ghcid. There's also another utility available that came along with having GHC installed for our haskell environment, ghci.

First, ghci is a Haskell repl that we can load our code into for testing and introspection. What is the type of the composition of two of our functions, with one partially applied? Just type it into ghci with the :t prefix to find out. This tool can do a whole lot more too!

To make our experience even better, we can have a file called .ghci in our project root which will run some commands when ghci starts up.

-- .ghci
:set -fwarn-unused-binds -fward-unused-imports
:add src/MyLib.hs src/Main.hs test/Spec.hs

The first line ensures we get warnings for unused variable bindings, and unused imports, because hygeine is important. The second line imports the modules that we want in scope in our ghci session.

You may have noticed that this means every time we add a file to our project, we have to include it here. This may not be a big deal, but could be an annoyance. I imagine there's a way to get around this, but I'm not sure what it is yet.

On top of being a powerful repl tool, ghci also powers the ghcid tool (as the name suggests). ghcid simply runs ghci as a daemon, reloading everything when source files are changed.

The result is very quick feedback from GHC on what is wrong with our code. I mentioned previously being able to "have a conversation" with the compiler, and ghcid is exactly how we make that conversation fluid.

I typically like to have a tmux split open next to vim with ghcid running within a nix-shell. I also pass a command to it so it knows how to run my test suite upon successful compilation (which I'll talk about more in the next section).

$ nix-shell --run "ghcid --test Spec.run"

At this point I can work on my code in vim and get real time feedback in a tmux split from ghcid, and upon successful compilation, my test suite will run. Neat! You can learn about more GHCid features in this article

Testing with hspec

In the last section, I mentioned passing an argument to our ghcid command that would allow our test suite to run after a successful compilation. That test suite is being run with hspec.

To make hspec available in our project, we need to add it to our cabal dependencies, but we do this in a specific area of the file, under a test-suite section. Add the following to the bottom of your cabal file.

test-suite tests
type: exitcode-stdio-1.0
hs-source-dirs: test
main-is: Main.hs
other-modules: Spec

build-depends: base ^>=4.12.0.0,
hspec,
your-project

Here we are specifying a test-suite for our project named tests (cabal allows you to have multiple test suites defined). The tests are contained in the test folder, and the main module to run is Main.hs. Our dependencies outside of base are our project lib, and hspec. Lastly, we want to define one other module, Spec.

It's important to note here that all our Main.hs module does is run the test suite with Spec.run. The reason for this is so we can run our test suite in ghcid without having a collision on the Main module name. You'll notice in the .ghci configuration above, we don't include test/Main.hs, specifically to avoid that naming collision.

Here's our entire test/Main.hs file.

-- test/Main.hs
module Main where

import Spec

main :: IO ()
main = Spec.run

..and here's a small sample file for test/Spec.hs.

module Spec where

import Test.Hspec
import YourLib (add5)

run :: IO ()
run = hspec $ do
describe "add5" $ do
it "adds 5 to the provided Int" $ do
add5 3 `shouldBe` 8

Now we can add to this as we go, and ghcid will automatically run them on any code changes, which makes for a very nice development experience. Awesome.

Hoogle, documentation and type lookups

One of our goals, the first one actually, is efficient access to information. Some of that information is about our local code, but we are also going to need access to information about the dependencies we're using, and Prelude.

Thankfully we installed a handy tool called hoogle that let's us search for that information, as well as a vim plugin for using and browsing hoogle search results! Hoogle even lets us search by type, which can help us find something to fit exactly the spot we need in our code.

Building the project

There's still a lot I have to learn about building nix derivations, and cabal packages, and distributing them, so all I can really cover here is how to build an executable with cabal (or nix) and run it.

To build and run it with cabal..

$ cabal v2-build
$ cabal v2-run your-executable

To build it with nix..

$ nix-build

In the output of nix-build, you should see a path to a bin folder, with your executable inside it. You can run it from there to test it out.

That's it for now!

I'm sure there will be many ways in which this setup changes over time, but this is what I have for now, and hopefully it's a nice and simple setup that others new to the worlds of Nix and Haskell can benefit from. Both tools are incredibly fun to work with, and powerful tools to have at your disposal. I look forward to sharing more about them in the future!