Haskell Toolchain with mise + GHCup
The native alternative to the devcontainer: the toolchain runs directly on the host, with no Docker, no VM and no bind-mount I/O penalty. mise owns the pinnable binaries and puts them on PATH per project; ghcup owns GHC and HLS, which mise cannot install.
Detection — check before asking
ls mise.toml .mise.toml 2>/dev/null # project pin present?
mise ls 2>/dev/null | grep -E '^(ghcup|cabal|ormolu)'
ghcup list -c installed -t ghc 2>/dev/null
If mise.toml pins ghcup/cabal → the native path is already set up. Acknowledge it and proceed.
Division of labor
| Tool | Provisioned by | Why |
|---|---|---|
ghcup |
mise (aqua:haskell/ghcup-hs) |
pinnable per project, bootstraps the rest |
cabal |
mise (aqua:haskell/cabal/cabal-install) |
exact versions available, switches per project |
ormolu |
mise (github:tweag/ormolu) |
ships an aarch64-darwin binary |
| GHC | ghcup | not installable via mise: the ghc registry entry is conda:ghc (needs conda) and the mise-ghcup asdf plugin was archived in Nov 2025. mise ls-remote ghc returns nothing |
| HLS | ghcup | bindists are compiled against specific GHC patch versions; ghcup is the only thing that matches them correctly |
hlint |
Homebrew or cabal | not via mise — see the Apple Silicon note below |
cabal-gild, hoogle, fast-tags, doctest, DAP adapters |
cabal install |
not in the mise registry; they are GHC-compiled anyway |
mise.toml
Commit at the repo root:
[tools]
ghcup = "latest"
cabal = "3.12.1.0"
# Keep the CLI in the series HLS bundles (HLS 2.14 accepts ormolu ^>=0.8),
# otherwise editor formatOnSave and the PostToolUse hook reformat each other.
ormolu = "0.8.1.1"
[env]
# GHC and HLS come from ghcup; cabal.project's with-compiler selects the exact
# compiler per project. 9.10.3 is the only 9.10.x with an HLS 2.14 bindist —
# do not "downgrade" to 9.10.1 or 9.10.2.
GHC_VERSION = "9.10.3"
[tasks.setup]
description = "Provision the GHC/HLS half of the toolchain via ghcup"
run = [
"ghcup install ghc 9.10.3",
"ghcup install hls recommended",
]
Onboarding is then mise install && mise run setup — the native equivalent of the devcontainer's post-create.sh. Both commands are idempotent.
Version compatibility — the HLS bindist decides the GHC
This is the one place where picking versions independently breaks the setup. HLS ships prebuilt binaries for an explicit list of GHC patch versions; a GHC outside that list leaves the project with no working language server.
Choose the GHC from the HLS support list, never the reverse. Verify before pinning:
ghcup list -t hls # which HLS is tagged recommended
ghcup install hls recommended
ls ~/.ghcup/bin/haskell-language-server-* # one binary per supported GHC
haskell-language-server-wrapper --probe-tools
The GHC being pinned must appear in that ls output. Snapshot verified 2026-09 — treat it as dated and re-run the commands above rather than trusting the table:
HLS tagged Recommended/Latest in ghcup |
2.14.0.0 |
| GHCs with a bindist in HLS 2.14.0.0 | 9.14.1, 9.12.4, 9.12.2, 9.10.3, 9.8.4, 9.6.7 |
| ormolu accepted by HLS 2.14.0.0 | ^>=0.5.3 || ^>=0.6 || ^>=0.7 || ^>=0.8 |
| doctest against the GHC API | 0.22.x: ghc >=8.0 && <9.12 — fine on 9.10.3 |
If the wanted GHC has no bindist, the options are to move the pin to one that does, or ghcup compile hls --version <v> --ghc <ghcver> (expensive, builds from source).
GHC and HLS via GHCup
ghcup install ghc 9.10.3 # provides ~/.ghcup/bin/ghc-9.10.3
ghcup install hls recommended
ghcup set is required, even though cabal does not need it. with-compiler: ghc-9.10.3 in
cabal.project selects the versioned binary directly, so cabal builds fine without it and
projects pinned to different GHCs coexist. HLS is the reason to run it anyway: it looks for a
bare ghc on PATH, and a fresh ghcup install leaves only ghc-9.10.3, ghc-pkg-9.10.3 and
friends. Skipping it produces
Failed to find executable "ghc" in $PATH for this Default project
as soon as a .hs file is opened. Re-run it whenever the pinned GHC changes:
ghcup set ghc 9.10.3 # creates the unversioned ghc, ghc-pkg, ghci, haddock, runghc symlinks
The global switch only affects the Default cradle (a loose .hs file outside a project). A
cabal project still takes its compiler from with-compiler, so this does not interfere with
per-project pins.
PATH and shims
Three directories must be on PATH, in this order:
# ~/.zshrc
eval "$(mise activate zsh)" # mise shims: ghcup, cabal, ormolu
export PATH="$HOME/.ghcup/bin:$PATH" # ghc-<ver>, haskell-language-server-*
export PATH="$(cabal path --installdir):$PATH"
Do not hardcode cabal's install directory. Cabal 3.10+ uses XDG paths (~/.local/bin) on a clean install but keeps ~/.cabal/bin when ~/.cabal already exists — cabal path --installdir reports the truth.
This wiring is load-bearing, not cosmetic: the global PostToolUse formatting hook runs command -v ormolu / command -v cabal-gild, and the haskell-lsp plugin launches a bare haskell-language-server-wrapper. Both resolve from the non-interactive shell's PATH; if the binaries are missing there, formatting and LSP fail silently. Verify with zsh -lc 'command -v ormolu cabal-gild haskell-language-server-wrapper'.
.envrc for the native path
# .envrc — committed to the repo
use mise
export DATABASE_URL="postgres://localhost/myapp_dev"
use mise activates the project's pinned tools on cd. Run direnv allow once per project — natively this is manual, whereas the devcontainer automates it in post-start.sh. .direnv/ is gitignored.
.tool-versions vs mise.toml
mise reads .tool-versions natively, which is a trap here: a ghc 9.10.3 line makes mise install reach for the conda backend and fail. On the native path use mise.toml only. If the repo needs a .tool-versions anyway (CI, asdf, or a contributor on plain ghcup), tell mise to stay out of it:
[settings]
disable_tools = ["ghc"]
hlint on Apple Silicon
hlint publishes no aarch64-darwin release asset. mise install hlint still reports success — it silently unpacks the source tarball, leaving no executable behind. Use Homebrew instead (arm64 bottle, same version), or build it:
brew install hlint # preferred
cabal install hlint # fallback, compiles from source
Keep hlint out of mise.toml so the failure cannot reappear, and note it in the project README next to the pinned versions.
macOS prerequisites
xcode-select --install is required before the first ghcup install ghc. If a package with C bindings fails to build, install the library it wants — brew install pkg-config libffi gmp openssl zlib covers the usual suspects. Do this on first failure, not preemptively.
Editor integration
Do not use manageHLS: "GHCup" on this path. That mode makes the extension manage the
ghcup installation — it runs ghcup upgrade on startup — but here ghcup belongs to mise, and
mise shims resolve their version from the current directory. The extension host runs outside
the workspace, so the call fails:
mise ERROR No version is set for shim: ghcup → exit 1
surfaced as '.../shims/ghcup --no-verbose upgrade' exited with exit code 1. Pointing
haskell.ghcupExecutablePath at ~/.ghcup/bin/ghcup does not help either: with a
mise-provisioned ghcup that file does not exist — the binary lives under
~/.local/share/mise/installs/ghcup/.
ghcup has already installed HLS via mise run setup, so the extension only has to consume it:
{
"haskell.manageHLS": "PATH",
"haskell.serverExecutablePath": "/absolute/path/to/.ghcup/bin/haskell-language-server-wrapper",
"haskell.formattingProvider": "ormolu",
"[haskell]": {
"editor.formatOnSave": true
},
"haskell.serverEnvironment": {
"PATH": "<home>/.ghcup/bin:<home>/.local/share/mise/shims:<cabal installdir>:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
}
}
haskell.serverEnvironment is not optional on macOS: GUI apps do not inherit the login shell
PATH, and VS Code caches the resolved environment at startup, so a ~/.zshrc edit does not
reach an already-running window. Without it the server cannot find ghc or cabal.
Put this in VS Code's User settings, not in the repo. The paths describe the machine, not the
project, and none of them carry a version number, so they survive GHC/HLS/cabal upgrades. Keeping
them out of .vscode/settings.json also avoids committing absolute /Users/<name>/… paths, and
every new Haskell project then works with no editor setup at all.
Scope editor.formatOnSave inside the [haskell] block. At the top level of User settings it
would enable format-on-save for every language on the machine.
The catch to know about: since the shims directory is on that PATH and mise resolves by
directory, every Haskell project needs its own mise.toml pinning cabal and ormolu. A
project without those pins fails the same way, just naming cabal instead of ghcup.
Verify the whole chain with a clean environment, which is what the extension effectively has:
env -i HOME="$HOME" PATH="<the PATH above>" haskell-language-server-wrapper --probe-tools
cabal and ghc must both be reported, and the cradle must be the project's, not Default.
Commit .vscode/extensions.json with the same extension list the devcontainer declares, so a host VS Code gets the same recommendations.
CI
CI has no mise and no container. Mirror the pins explicitly with haskell-actions/setup (ghc-version, cabal-version matching mise.toml) — see haskell-documentation for the workflow. The pins must be updated in both places together.
Native or devcontainer?
- Native (this skill) — no Docker, no VM overhead, full speed on Apple Silicon, host editor and tooling work directly. Costs a one-time toolchain install per machine.
- Devcontainer (
haskell-devcontainer) — parity with a Linux CI, disposable environment, and Docker-based services (Postgres) alongside the app. Costs virtualization overhead on macOS.
Related
haskell-project-setup— project layout,.cabal/cabal.projecttemplates, language extensions.haskell-devcontainer— the containerized alternative.haskell-quality-gates— the formatter/linter these pins feed, and the pre-commit hooks that need them onPATH.