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 —
misepins ghcup/cabal/ormolu, ghcup provides GHC and HLS. Seehaskell-toolchain-mise. - Pre-built devcontainer (
ivelten/haskell-devcontainer) — seehaskell-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). Neverstack, never Nix shells, unless requested. - GHC version: 9.10.3 (pinned in
mise.toml/.tool-versionsandcabal.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— seehaskell-effectfulskill. - Testing:
hspec+QuickCheck+hspec-discover— seehaskell-testing. - Logging:
log-effectful— seehaskell-logging. - Formatter:
ormolu(zero-config, opinionated — no style debates) — seehaskell-quality-gates. - Linter:
hlint— apply its suggestions by default unless they conflict with effectful/clarity. - Language server: HLS, installed via
ghcup install hls recommendedand 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 — seehaskell-toolchain-misefor 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
cabal install hoogle
hoogle generate # builds the local search index (run once, re-run after major dep changes)
After generation, search from the terminal:
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
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
# 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:
# .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 declare9.10upper-bound support. Ifcabal buildfails to find a build plan, the most likely cause is a transitive dependency without a bumped upper bound — usually fixed by waiting or adding anallow-newerline tocabal.project. - Use
GHC2021asdefault-languageeven on GHC 9.10 —GHC2024enables 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:
## 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-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.0is required forcommonstanzas and the leading-commabuild-dependssyntax.- 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-haddockis library-only. Haddock coverage is enforced by a script, not a compiler flag — seehaskell-documentationfor 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.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.cabalfiles, list them all. For a single-package project, just..with-compiler: forces cabal to use a specific GHC binary. If not onPATH, build fails immediately rather than silently using whatever GHC is around. Keep in sync withmise.toml/.tool-versions.index-state: pins which Hackage index version cabal uses for dependency resolution. Without this, two developers runningcabal buildon different days can get different dependency versions even with identical.cabalconstraints.tests: Trueandtest-show-details: direct: makescabal buildalso build tests, and makescabal teststream output live with colors instead of buffering.
Module conventions
- Explicit export lists, always.
module MyProject.User (User, registerUser, getUser) where - Internal modules under
.Internalwhen 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 indefault-extensionsorGHC2021.
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 explicitderiving stock/newtype/via. Good hygiene.DerivingVia— enablesderiving X via Yclauses.ImportQualifiedPost— allowsimport Data.Map qualified as M(postfixqualified, cleaner alignment).LambdaCase—\case ....OverloadedStrings—"text"literals can beText,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 foreffectfuleffect definitions.TypeFamilies— needed fortype instance DispatchOfdeclarations in effects.NamedFieldPuns— when pattern matching on records (seehaskell-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— seehaskell-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.