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-docsis not a GHC warning. GHC rejects it as[GHC-93741] unrecognised warning flag. Putting it inghc-optionsis 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 bareCabal-7125and no explanation of the real cause. Haddock's only warning controls are-w/--no-warningsand--ignore-link-symbol.
Re-verify rather than trusting this note when the GHC pin moves:
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:
./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
-- | 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 ^:
-- | 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:
-- | 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:
-- | User registration and authentication primitives.
module MyProject.User
( User
, UserId
, registerUser
, authenticateUser
) where
For modules with non-trivial scope, expand to multiple paragraphs:
-- | 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:
- Readers grasp intent faster from a worked example than from a type signature.
- Examples can become executable doctests (see below) and stay verified.
Use the @...@ block for code that should render in a monospace font:
-- | 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 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):
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:
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):
-- | 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 (seehaskell-testing). Doctest handlesIObut 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:
./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 multiplefooare 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-quickjumpproduces 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:
- The coverage gate catches forgotten Haddock on new exports. A new export drops coverage below 100% and fails the check.
- Doctests catch examples that no longer match behavior. Examples become regression tests; run as part of
cabal test. - The same gate catches broken references. Renamed functions don't leave dangling links in the docs.
- Code review focuses on Haddock changes alongside code changes. When a function's signature or behavior changes, the reviewer asks: was the Haddock updated?
- 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:
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-sourceturns identifiers into clickable links to the rendered source — invaluable for readers exploring an unfamiliar codebase.--haddock-quickjumpadds a keyboard-accessible symbol search bar at the top of every page.- The
check-haddock.shstep 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-Werrordoes not exist in Haddock 2.31.1. - The
find ... | while readloop 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.htmlat the root of_siteis 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
mainonly keeps Pages aligned with the latest released code. If you publish per-tag instead, addtags: ['v*']and remove thebranchesfilter.
Trade-offs vs alternatives
- vs
gh-pagesbranch (peaceiris/actions-gh-pages): the modernactions/deploy-pagesflow 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 andgit 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 --openlocally 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.