# Haskell Project Setup

> Set up a new Haskell project with the opinionated cabal stack, independent of how the toolchain was provisioned. Covers GHC pinning, cabal.project, .cabal templates, project layout, and language-extension policy. Use when bootstrapping a Haskell repo, configuring the build system, deciding where files go, or choosing which language extensions to enable.

- Skill: `ivelten/haskell-project-setup` (Agent Skill)
- Install (CLI): `npx skillmds@latest add ivelten/haskell-project-setup`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ivelten/haskell-project-setup/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ivelten (https://skillmd.com/u/ivelten)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/ivelten/haskell-project-setup

---


# Haskell Project Setup

This skill encodes the build, layout, and language-extension defaults for a Haskell project.

## Development environment (first step)

Before anything else, the project needs a toolchain. The choice is made in the `haskell` router skill, which weighs the two options:

- **Native on the host** — `mise` pins ghcup/cabal/ormolu, ghcup provides GHC and HLS. See `haskell-toolchain-mise`.
- **Pre-built devcontainer** (`ivelten/haskell-devcontainer`) — see `haskell-devcontainer`.

This skill is agnostic to that choice: everything below applies once a toolchain is on `PATH`.

## The Stack (non-negotiable defaults)

Unless the user explicitly asks for something different, use:

- **Build tool**: `cabal` (with GHCup-provisioned GHC). Never `stack`, never Nix shells, unless requested.
- **GHC version**: 9.10.3 (pinned in `mise.toml`/`.tool-versions` and `cabal.project`). It is the only 9.10.x with an HLS 2.14 bindist — moving to 9.10.1 or 9.10.2 would break the language server.
- **Effects**: [`effectful`](https://hackage.haskell.org/package/effectful) — see `haskell-effectful` skill.
- **Testing**: `hspec` + `QuickCheck` + `hspec-discover` — see `haskell-testing`.
- **Logging**: `log-effectful` — see `haskell-logging`.
- **Formatter**: `ormolu` (zero-config, opinionated — no style debates) — see `haskell-quality-gates`.
- **Linter**: `hlint` — apply its suggestions by default unless they conflict with effectful/clarity.
- **Language server**: HLS, installed via `ghcup install hls recommended` and not pinned. The dependency runs the other way from what it looks like: HLS ships bindists for specific GHC *patch* versions, so the **GHC is chosen from the HLS support list**, never the reverse — see `haskell-toolchain-mise` for the verification commands. Design code that works well with it: explicit type signatures on top-level bindings, no orphan instances.
- **Documentation**: Haddock + Doctest — see `haskell-documentation`.

## Additional development tools

Install these alongside the core toolchain. They're not build dependencies — they land in cabal's install directory (`cabal path --installdir`: `~/.local/bin` under XDG, `~/.cabal/bin` on older layouts) and support editor/shell workflows. That directory must be on `PATH`.

### Hoogle — local API search

```bash
cabal install hoogle
hoogle generate   # builds the local search index (run once, re-run after major dep changes)
```

After generation, search from the terminal:

```bash
hoogle "ByteString -> Text"   # search by type signature
hoogle foldr                  # search by name
```

Or from GHCi (with HLS active, `:hoogle` is available directly as a command). Hoogle is essential when working with unfamiliar Hackage libraries — always prefer it over guessing API shapes.

### fast-tags — tag file generation

```bash
cabal install fast-tags
fast-tags -R .   # generate tags for the whole project (TAGS for emacs, tags for vim/neovim)
```

Enables go-to-definition for editors that use ctags/etags (Vim, Neovim, Emacs). HLS covers this for VS Code, but fast-tags is a lightweight fallback that works without a running language server. Add a `make tags` target or a shell alias if using it regularly.

### direnv — per-project environment

```bash
# Install via package manager or GitHub releases (prefer releases to avoid stdlib CVEs)
# Then add to shell:
eval "$(direnv hook bash)"   # or zsh
```

Add a `.envrc` at the project root to activate GHCup-managed tools automatically:

```bash
# .envrc
export PATH="$HOME/.cabal/bin:$HOME/.ghcup/bin:$PATH"
```

Run `direnv allow` once per project. After that, entering the directory activates the environment. Commit `.envrc` so all contributors get consistent paths; `.direnv/` (the cache) is gitignored.

direnv pairs naturally with `cabal.project`'s `with-compiler` pin: the project refuses to build with the wrong GHC, and direnv ensures the right one is first on PATH.

## Toolchain Pinning

Pin GHC and cabal at the repo level so builds are reproducible and contributors don't waste time debugging "works on my machine."

### `mise.toml` (native path)

When the toolchain is provisioned with `mise`, the pin lives in `mise.toml` — see `haskell-toolchain-mise` for the full file. GHC itself is not pinnable by mise and comes from `ghcup`.

### `.tool-versions`

Read by `asdf`, recent `ghcup`, and `mise`. Commit at repo root:

```
# .tool-versions
ghc 9.10.3
cabal 3.12.1.0
```

⚠️ mise also reads `.tool-versions`, and a `ghc` line there makes `mise install` reach for a backend that cannot provide GHC. On the native path either drop `.tool-versions` in favour of `mise.toml`, or add `[settings] disable_tools = ["ghc"]` to `mise.toml`.

Notes on the version choices:
- **GHC 9.10.3** (2024) — modern features available (e.g. `GHC2024`), but some older libraries on Hackage may not yet declare `9.10` upper-bound support. If `cabal build` fails to find a build plan, the most likely cause is a transitive dependency without a bumped upper bound — usually fixed by waiting or adding an `allow-newer` line to `cabal.project`.
- Use `GHC2021` as `default-language` **even on GHC 9.10** — `GHC2024` enables several extensions (e.g. `DataKinds`) by default that should be opt-in per module.

### Document in README

Spell out the installation steps for contributors, matching whichever path the project uses:

```markdown
## Toolchain

This project requires:
- GHC 9.10.3
- cabal 3.12.1.0
- HLS — let ghcup pick the version compatible with GHC 9.10

Install via [GHCup](https://www.haskell.org/ghcup/):
    ghcup install ghc 9.10.3
    ghcup install cabal 3.12.1.0
    ghcup install hls recommended
    ghcup set ghc 9.10.3
    ghcup set cabal 3.12.1.0

Or, with [mise](https://mise.jdx.dev/) managing the pinned binaries:
    mise install        # ghcup, cabal, ormolu from mise.toml
    mise run setup      # ghc + hls via ghcup
```

`ghcup install hls recommended` installs the HLS release GHCup tags as stable; confirm it shipped a binary for the pinned GHC with `ls ~/.ghcup/bin/haskell-language-server-*`.

## Project Layout

```
my-project/
├── mise.toml                 -- pinned ghcup/cabal/ormolu (native path)
├── .tool-versions            -- pinned GHC/cabal versions (CI, asdf, ghcup)
├── .pre-commit-config.yaml   -- formatter/linter hooks
├── .hlint.yaml
├── .hspec                    -- hspec output config
├── cabal.project             -- project-wide build config
├── my-project.cabal          -- package definition
├── src/
│   └── MyProject/
│       ├── Types.hs          -- Core domain types, no logic
│       ├── User.hs           -- Public API of the User domain
│       └── User/
│           ├── Effect.hs     -- UserStore effect definition
│           ├── Postgres.hs   -- Production interpreter
│           └── InMemory.hs   -- Test interpreter
├── app/
│   └── Main.hs               -- Composition root; runs interpreters
└── test/
    ├── Spec.hs               -- Just: {-# OPTIONS_GHC -F -pgmF hspec-discover #-}
    └── MyProject/
        └── UserSpec.hs
└── test-doctest/
    └── Doctest.hs            -- Doctest driver; runs >>> examples in Haddock
```

## `.cabal` template

Use a `common` stanza to share warnings, extensions, and language version across all components — DRY for build settings.

```cabal
cabal-version:      3.0
name:               my-project
version:            0.1.0.0
build-type:         Simple

common shared
  default-language:   GHC2021
  default-extensions:
    DerivingStrategies
    DerivingVia
    ImportQualifiedPost
    LambdaCase
    OverloadedStrings
    TypeApplications
  ghc-options:
    -Wall
    -Wcompat
    -Widentities
    -Wincomplete-record-updates
    -Wincomplete-uni-patterns
    -Wmissing-deriving-strategies
    -Wmissing-export-lists
    -Wpartial-fields
    -Wredundant-constraints

library
  import:           shared
  hs-source-dirs:   src
  exposed-modules:
    MyProject.User
    MyProject.User.Effect
  -- Catch Haddock comments attached to nothing. Library-only: tests and
  -- executables don't need Haddock on every function.
  -- NOT -Wmissing-docs: GHC has no such flag and rejects it as unrecognised,
  -- which silently disables the enforcement. Coverage is gated by
  -- scripts/check-haddock.sh instead -- see `haskell-documentation`.
  ghc-options:      -Winvalid-haddock
  build-depends:
    , base         ^>=4.20
    , text
    , effectful

executable my-project
  import:           shared
  hs-source-dirs:   app
  main-is:          Main.hs
  build-depends:
    , base
    , my-project

test-suite my-project-test
  import:           shared
  type:             exitcode-stdio-1.0
  hs-source-dirs:   test
  main-is:          Spec.hs
  build-depends:
    , base
    , my-project
    , hspec        ^>=2.11
    , QuickCheck   ^>=2.14
  build-tool-depends:
    hspec-discover:hspec-discover

test-suite my-project-doctest
  import:           shared
  type:             exitcode-stdio-1.0
  hs-source-dirs:   test-doctest
  main-is:          Doctest.hs
  build-depends:
    , base
    , doctest      ^>=0.22
```

Key points:
- `cabal-version: 3.0` is required for `common` stanzas and the leading-comma `build-depends` syntax.
- Each component imports the shared block via `import: shared`.
- Warnings beyond `-Wall` (`-Wcompat`, `-Widentities`, etc.) catch real bugs and stylistic regressions. Worth enforcing from day one.
- `-Winvalid-haddock` is library-only. Haddock *coverage* is enforced by a script, not a compiler flag — see `haskell-documentation` for why.

## `cabal.project`

Where `.cabal` describes a single package, `cabal.project` configures the build of the whole project — which packages, which GHC, dependency resolution, global test options.

```cabal
-- cabal.project

-- Which packages are part of this build. Use ./pkg1 ./pkg2 for monorepos.
packages: .

-- Pin GHC version. Must match the pin in mise.toml/.tool-versions.
-- Cabal will refuse to build with a different compiler.
with-compiler: ghc-9.10.3

-- Pin the Hackage snapshot for reproducible builds. Update deliberately
-- when you want to pick up new dependency versions.
index-state: 2025-01-15T00:00:00Z

-- Tests on by default.
tests: True

-- Stream test output directly to terminal, preserving colors.
test-show-details: direct
```

What each line does:
- **`packages`**: discovery list. In a monorepo with multiple `.cabal` files, list them all. For a single-package project, just `.`.
- **`with-compiler`**: forces cabal to use a specific GHC binary. If not on `PATH`, build fails immediately rather than silently using whatever GHC is around. Keep in sync with `mise.toml`/`.tool-versions`.
- **`index-state`**: pins which Hackage index version cabal uses for dependency resolution. Without this, two developers running `cabal build` on different days can get different dependency versions even with identical `.cabal` constraints.
- **`tests: True`** and **`test-show-details: direct`**: makes `cabal build` also build tests, and makes `cabal test` stream output live with colors instead of buffering.

## Module conventions

- **Explicit export lists, always.** `module MyProject.User (User, registerUser, getUser) where`
- **Internal modules under `.Internal`** when you need to expose for testing but not as public API.
- **Per-module `{-# LANGUAGE ... #-}` pragmas** at the top, alphabetical, one per line — only for extensions not already in `default-extensions` or `GHC2021`.

## Language Extensions

`GHC2021` (set as `default-language`) already includes the sensible baseline: `FlexibleContexts`, `FlexibleInstances`, `MultiParamTypeClasses`, `KindSignatures`, `RankNTypes`, `ScopedTypeVariables`, and many more. **Do not list these in `default-extensions`** — they're already on.

### Project-wide (in `.cabal` as `default-extensions`)

Extensions that are ubiquitous, semantically harmless, and don't surprise an experienced reader:

- `DerivingStrategies` — forces explicit `deriving stock` / `newtype` / `via`. Good hygiene.
- `DerivingVia` — enables `deriving X via Y` clauses.
- `ImportQualifiedPost` — allows `import Data.Map qualified as M` (postfix `qualified`, cleaner alignment).
- `LambdaCase` — `\case ...`.
- `OverloadedStrings` — `"text"` literals can be `Text`, `ByteString`, etc.
- `TypeApplications` — `read @Int "42"`.

### Per-module (`{-# LANGUAGE ... #-}` pragma)

Extensions that signal "something unusual is happening in this module" — local declaration is informative for the reader:

- `BangPatterns` — explicit strictness annotations.
- `DataKinds` — type-level data, usually paired with type-level programming.
- `GADTs` — needed for `effectful` effect definitions.
- `TypeFamilies` — needed for `type instance DispatchOf` declarations in effects.
- `NamedFieldPuns` — when pattern matching on records (see `haskell-type-design`).

### Avoid by default

- `RecordWildCards` — pulls all fields into scope implicitly; hides what's bound.
- `OverloadedRecordDot` — visually conflicts with function composition `(.)`; uneven IDE support.
- `NoFieldSelectors` / `DuplicateRecordFields` — see `haskell-type-design`; prefer prefixed field names.
- `ImpredicativeTypes` — fragile, breaks in subtle ways.
- `UndecidableInstances` — last resort; usually a sign the design is fighting the type system.

