# Haskell Documentation

> Write and enforce Haskell documentation with Haddock + Doctest. Covers the syntax cheat sheet (before/after declarations, records, sum types, module headers), the Haddock coverage gate (`-Wmissing-docs` and `haddock -Werror` do not exist on GHC 9.10.3 — use `scripts/check-haddock.sh`), executable examples via doctest, and how to keep docs from drifting from code. Use when adding documentation, configuring the doctest test-suite, deciding what to put in Haddock vs hspec, or debugging Haddock warnings.

- Skill: `ivelten/haskell-documentation` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add ivelten/haskell-documentation`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ivelten/haskell-documentation/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- Author: ivelten (https://skillmd.com/u/ivelten)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/ivelten/haskell-documentation

---


# Haskell Documentation

**Haddock on every public binding is non-negotiable.** Every exported function, type, type constructor, data constructor, type class, type class method, and module gets a Haddock comment. Enforcement is a **Haddock coverage gate** (`scripts/check-haddock.sh`), not a compiler warning — see "Enforcement: what actually works" below before reaching for `-Wmissing-docs`.

The discipline is not "document everything that exists" — it's "document everything users of the module see." Internal helpers, private functions, and instances of derived classes don't need it.

The combined enforcement is what makes Haddock more than aspirational: coverage is verified by the gate script, examples run as tests (doctest), and unresolved cross-references fail that same script.

## Enforcement: what actually works

Two flags are widely cited for this job and **neither exists on the pinned toolchain**. Verified on GHC 9.10.3 / Haddock 2.31.1 (2026-09):

- **`-Wmissing-docs` is not a GHC warning.** GHC rejects it as `[GHC-93741] unrecognised warning flag`. Putting it in `ghc-options` is worse than useless: the build emits a warning about the flag itself on every compile, while documentation goes completely unenforced. GHC's nearest real flag is **`-Winvalid-haddock`**, which catches Haddock comments in positions where they attach to nothing — useful, but not a coverage check. Put that one in the library stanza instead.
- **Haddock 2.31.1 has no `-Werror`.** `cabal haddock --haddock-options="-Werror"` fails with a bare `Cabal-7125` and no explanation of the real cause. Haddock's only warning controls are `-w`/`--no-warnings` and `--ignore-link-symbol`.

Re-verify rather than trusting this note when the GHC pin moves:

```bash
ghc --show-options | grep -x -- -Wmissing-docs      # no output = not supported
haddock --help | grep -i -- -Werror                 # no output = not supported
```

**What to use instead.** Haddock already reports per-module coverage (`100% ( 6 / 6) in 'MyProject.User'`) and prints unresolved-reference warnings; the gate just has to read them and set an exit code. Copy `scripts/check-haddock.sh` from this skill into the project and wire it into the checklist and CI:

```bash
./scripts/check-haddock.sh    # fails below 100% coverage or on unresolved references
```

It runs `cabal haddock all`, fails if Haddock exits non-zero, fails on `out of scope` / `could not find documentation` / `ambiguous`, and fails if any coverage line is under 100%.

## Proactively suggest publishing

When this skill is active because the user is doing substantive documentation work — adding Haddock to new modules, polishing docs before a release, setting up doctest, or otherwise treating docs as a deliverable — proactively raise the option of publishing Haddock to GitHub Pages, **once per project conversation**. If the user hasn't set it up and doesn't decline, offer the workflow in "Publishing to GitHub Pages" below.

Skip the suggestion on:

- Trivial doc edits (typo fixes, single-line tweaks).
- Projects the user has indicated are internal/non-public.
- Conversations where the user has already declined publishing.

Frame the suggestion as a one-time setup that pays off on every subsequent doc change, not as required ceremony. The user owns whether to do it.

## Haddock syntax cheat sheet

```haskell
-- | Documents the *next* declaration. Most common.
foo :: Int -> Int

-- | Multi-line Haddock works exactly like Markdown.
--
-- Blank comment lines create paragraphs.
bar :: Int

-- ^ Documents the *previous* thing. Useful for record fields and
-- function arguments because it sits next to what it describes.
```

For records, prefer field-level Haddock with `^`:

```haskell
-- | A registered user in the system.
data User = User
  { userId    :: !UserId
    -- ^ Stable identifier, assigned at registration. Never reused.
  , userEmail :: !Email
    -- ^ Primary contact. Unique across the user table.
  , userName  :: !Text
    -- ^ Display name. May contain Unicode; not used for authentication.
  }
```

For sum types, document each constructor:

```haskell
-- | Result of attempting to charge a payment method.
data PaymentResult
  = PaymentApproved TransactionId
    -- ^ Charge succeeded. Carries the gateway's transaction ID for reconciliation.
  | PaymentDeclined DeclineReason
    -- ^ Charge was rejected by the gateway. The reason can be surfaced to the user.
  | PaymentRetryable Text
    -- ^ Transient failure (timeout, network). Safe to retry with backoff.
```

## Module headers

Every module gets a header Haddock block. At minimum, a one-line summary:

```haskell
-- | User registration and authentication primitives.
module MyProject.User
  ( User
  , UserId
  , registerUser
  , authenticateUser
  ) where
```

For modules with non-trivial scope, expand to multiple paragraphs:

```haskell
-- | User registration and authentication.
--
-- This module is the public API for the user subsystem. Internal
-- representations live in @MyProject.User.Internal@; effect handlers
-- live in @MyProject.User.Effect@ and its submodules.
--
-- Typical usage:
--
-- @
-- user <- registerUser email password
-- session <- authenticateUser user.userEmail password
-- @
module MyProject.User (...) where
```

## When to include examples

For non-trivial functions, include at least one usage example in the Haddock. Two reasons:

1. Readers grasp intent faster from a worked example than from a type signature.
2. Examples can become **executable doctests** (see below) and stay verified.

Use the `@...@` block for code that should render in a monospace font:

```haskell
-- | Parse a string into an 'Email', validating format and length.
--
-- Returns 'Left' with a descriptive error on any failure.
--
-- @
-- parseEmail "alice@example.com"  -- Right (Email "alice@example.com")
-- parseEmail ""                   -- Left EmptyEmail
-- parseEmail "no-at-sign"         -- Left (InvalidFormat ...)
-- @
parseEmail :: Text -> Either EmailError Email
```

For complex types or non-obvious laws, link to authoritative references using Haddock's link syntax: `'Module.identifier'` for code references, `<https://...>` for URLs. Don't paraphrase a paper or RFC — link to it.

## Doctest

The [`doctest`](https://hackage.haskell.org/package/doctest) tool executes examples in Haddock comments as tests. **This is part of the default test setup** — examples in Haddock are not decorative, they're verified continuously.

### Cabal stanza

Add a dedicated test-suite alongside the main hspec one (see `haskell-project-setup` for placement in the full `.cabal`):

```cabal
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
```

Minimal driver at `test-doctest/Doctest.hs`:

```haskell
module Main (main) where

import Test.DocTest (doctest)

main :: IO ()
main = doctest
  [ "-isrc"
  , "src"
  ]
```

`doctest` discovers all `>>>` examples in Haddock comments under `src/` and verifies them. Run it with `cabal test my-project-doctest` (or just `cabal test` to run everything).

### Writing examples

Write examples with `>>>` followed by the expected output on the next line(s):

```haskell
-- | Compute the SHA-256 hash of a 'ByteString' as a hex string.
--
-- >>> hashHex "hello"
-- "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
--
-- >>> hashHex ""
-- "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
hashHex :: ByteString -> Text
```

Examples that no longer match the function's actual output break the build — documentation that drifts from reality is caught automatically.

### Practical rules for examples

- **Pure, deterministic functions** are ideal candidates: parsers, formatters, pure transformations. Same input → same output, every time.
- **Avoid examples in `IO`, `Eff`, or anything with side effects.** Use regular hspec tests for those (see `haskell-testing`). Doctest handles `IO` but it's brittle (depends on filesystem, time, randomness) and obscures the documentation value.
- **Keep examples short.** A Haddock example clarifies intent in seconds, not exhaustively test the function. Edge cases belong in hspec.
- **Show *what* the function does, not *how*.** An example with three function calls and a let-binding is a test in disguise.

When an example's output is long, structured, or hard to read, prefer a short example for the Haddock and put the heavy verification in hspec. **Doctest is documentation that happens to be checked, not the primary test mechanism.**

## When Haddock is *not* required

Haddock coverage counts **exported items only**, so the export list is the valve. Use it:

- Internal helpers: don't export them. If they're in the export list, they're public API and need docs.
- Test scaffolding: the gate runs `cabal haddock`, which only documents the library — tests and executables are never measured.
- Derived instances (`deriving stock (Eq, Show)`): the derivation itself doesn't need Haddock; the type does.

There is no per-item suppression: coverage is computed by Haddock, not by a GHC warning, so `-Wno-missing-docs` has nothing to switch off. An exported item that genuinely doesn't merit docs is a signal to stop exporting it — otherwise write the one line and move on.

## Haddock warnings on generation

Coverage catches *missing* documentation, but not *broken* documentation — links to identifiers that no longer exist, malformed markup, dangling references. Those only surface when you actually generate the Haddock HTML.

Since Haddock 2.31.1 has no `-Werror`, the gate script greps its output and sets the exit code:

```bash
./scripts/check-haddock.sh
```

This fails the build if Haddock reports any of:

- **`'Foo.bar' is out of scope`** — code reference to an identifier that doesn't exist or isn't imported.
- **`Could not find documentation for ...`** — link to a symbol Haddock can't resolve.
- **Malformed markup** — unclosed `@...@` blocks, unbalanced brackets in links.
- **Ambiguous references** — `'foo'` when multiple `foo` are in scope.

Common causes and fixes:

- **Identifier moved or renamed**: update the Haddock reference. Haddock doesn't track refactorings.
- **Reference to a private identifier**: either export it, or rephrase the doc to not link to it.
- **Cross-module reference**: use fully qualified form `'MyProject.User.userId'` when the symbol isn't in the current module's imports.
- **Linking to types from other packages**: works as long as those packages were built with Haddock enabled. `cabal haddock --haddock-hyperlink-source --haddock-quickjump` produces richer output but requires deps to have docs available.

## Keeping Haddock honest

Documentation drift — comments that no longer match the code — is worse than no documentation. The combined enforcement strategy:

1. **The coverage gate catches forgotten Haddock on new exports.** A new export drops coverage below 100% and fails the check.
2. **Doctests catch examples that no longer match behavior.** Examples become regression tests; run as part of `cabal test`.
3. **The same gate catches broken references.** Renamed functions don't leave dangling links in the docs.
4. **Code review focuses on Haddock changes alongside code changes.** When a function's signature or behavior changes, the reviewer asks: was the Haddock updated?
5. **Generate Haddock locally periodically.** Visual review catches awkward rendering, missing context, or unclear examples that compilation alone won't surface.

## Publishing to GitHub Pages

The cleanest way to publish Haddock is via GitHub Actions deploying to GitHub Pages — no `gh-pages` branch, no generated HTML committed to the repo, no `docs/` folder polluting diffs and `git blame`.

### One-time repo configuration

In **Settings → Pages → Source**, select **GitHub Actions**. After the first successful workflow run, docs are served at `https://<owner>.github.io/<repo>/`.

### Workflow file

Commit as `.github/workflows/haddock.yml`:

```yaml
name: Publish Haddock

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: pages
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # Mirror the pins from mise.toml/.tool-versions — CI has neither mise
      # nor the devcontainer image, so these must be updated together.
      - uses: haskell-actions/setup@v2
        with:
          ghc-version: '9.10.3'
          cabal-version: '3.12.1.0'

      - name: Cache cabal store
        uses: actions/cache@v4
        with:
          path: ~/.cabal/store   # ~/.local/state/cabal/store under XDG layouts
          key: cabal-${{ runner.os }}-${{ hashFiles('**/*.cabal', 'cabal.project') }}

      - name: Build dependencies
        run: cabal build all --only-dependencies

      - name: Check Haddock coverage and references
        run: ./scripts/check-haddock.sh

      - name: Generate Haddock
        run: |
          cabal haddock all \
            --haddock-hyperlinked-source \
            --haddock-quickjump

      - name: Stage docs
        run: |
          mkdir -p _site
          find dist-newstyle -type d -path '*/doc/html' | while read -r d; do
            for pkg in "$d"/*; do
              cp -r "$pkg" _site/
            done
          done
          # Redirect / to the main package's index.
          # Replace 'my-project' with the actual cabal package name.
          echo '<!doctype html><meta http-equiv="refresh" content="0; url=./my-project/index.html">' > _site/index.html

      - uses: actions/upload-pages-artifact@v3
        with:
          path: _site

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - id: deployment
        uses: actions/deploy-pages@v4
```

### Notes on the workflow

- **`--haddock-hyperlinked-source`** turns identifiers into clickable links to the rendered source — invaluable for readers exploring an unfamiliar codebase.
- **`--haddock-quickjump`** adds a keyboard-accessible symbol search bar at the top of every page.
- **The `check-haddock.sh` step** matches the local quality gate — if the gate passes locally, it passes here. Incomplete coverage or broken references stop the deploy before anything is published. It runs as its own step because `-Werror` does not exist in Haddock 2.31.1.
- **The `find ... | while read` loop** handles Haddock's nested output path (`dist-newstyle/build/<arch>/ghc-<ver>/<pkg>-<ver>/doc/html/<pkg>/`) without hardcoding platform or version segments. Works for both single-package and monorepo (`packages: ./pkg1 ./pkg2`) layouts.
- **The redirect `index.html`** at the root of `_site` is necessary because GitHub Pages serves the root directly — without it, visitors land on a directory listing or a 404. For multi-package projects, replace the redirect with a hand-written landing page that links to each package's docs.
- **Triggering on `main` only** keeps Pages aligned with the latest released code. If you publish per-tag instead, add `tags: ['v*']` and remove the `branches` filter.

### Trade-offs vs alternatives

- **vs `gh-pages` branch** (`peaceiris/actions-gh-pages`): the modern `actions/deploy-pages` flow is officially supported by GitHub and uses an artifact rather than a long-lived branch. Cleaner history, nothing to maintain.
- **vs committing `docs/` to the repo**: avoids polluting diffs and `git blame`, eliminates merge conflicts on generated HTML, removes the chore of remembering to regenerate before each PR. The user's source tree stays focused on source.
- **vs not publishing at all**: only worth doing if the project is public, has external consumers, or is shared across an internal team that benefits from a browsable reference. For a private solo tool, `cabal haddock --open` locally is usually enough.

### Cross-cutting reminder

The workflow's quality gates (`-Werror` on Haddock, build matrix, `cabal test`) overlap with the CI defined in `haskell-quality-gates`. Either keep them in one combined workflow file (build, test, publish in sequence) or in separate workflow files that share a cache key — duplicating dependency builds across two workflows is the most common waste.

See `haskell-quality-gates` for how these checks combine into the CI pipeline and the "before declaring work done" checklist.

