Languages No. 108

Haskell Functional skill for Claude Code

Total functions, typed domains, and leak-free IO. Claude writes the type signature first, models domains with sum types, strict fields and smart constructors, and replaces head, fromJust and read with total alternatives. It keeps a pure core under a ReaderT-over-IO shell, reads GHC errors one at a time with a decoded catalog, fixes space leaks with foldl' and strict maps, and gates builds on -Wall, hlint, hspec and QuickCheck.

  • namehaskell-functional
  • size244 lines · ≈2,550 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Writes and debugs practical Haskell — modelling domains with algebraic data types and newtypes, total functions with Maybe and Either instead of partial head and fromJust, typeclasses and deriving strategies, a thin IO shell around a pure core, strictness and space-leak fixes, Text and ByteString over String, exceptions in IO, mtl or ReaderT application structure, and cabal or stack project setup with GHC2021/GHC2024, HLS, hlint, and hspec or QuickCheck tests. Use when writing Haskell code, decoding a GHC type error, fixing a space leak or slow program, choosing between cabal and stack, or structuring a Haskell application.

Try asking

  • GHC says Couldn't match type Text with [Char] — explain and fix it
  • My Haskell program's memory keeps growing while summing a big file — find the leak
  • Set up a cabal project for a small Haskell web service with tests and warnings as errors

Use it when

  • Writing or reviewing Haskell code
  • A GHC type error message is hard to interpret
  • A Haskell program leaks memory or runs slowly
  • Structuring a Haskell application around IO, configuration, and state

Skip it when

How to check the result

cabal build with -Werror, hlint, and cabal test all pass; for performance fixes, +RTS -s shows lower maximum residency than before.

The common mistake it prevents

Using lazy foldl or Data.Map with insertWith on large inputs, which piles up unevaluated thunks until the program runs out of memory.

~/.claude/skills/haskell-functional/SKILL.md

--- name: haskell-functional description: Writes and debugs practical Haskell — modelling domains with algebraic data types and newtypes, total functions with Maybe and Either… license: MIT ---

Done well means: illegal states are unrepresentable in the types, every function is total (no head, fromJust, incomplete patterns), effects live at the edges, records and data use strict fields by default, and the project builds with -Wall -Werror in CI with hlint clean and tests passing.

Ground rules

  • Write the type signature first for every top-level binding. Let the compiler check your design before you write bodies; use typed holes (_) to ask GHC what fits.
  • No partial functions in application code: head, tail, !!, fromJust, read, maximum on possibly-empty lists. Use pattern matching, Data.List.NonEmpty, listToMaybe, readMaybe.
  • Text for human text, ByteString for bytes and wire data; String only for tiny scripts or APIs that demand it. Turn on OverloadedStrings.
  • Strict fields on data types (!Int) or StrictData per module unless you need laziness deliberately.
  • newtype for every domain identifier and unit (newtype UserId = UserId Int).
  • Pure core, IO shell: business logic takes and returns plain data; main and handlers do the IO.

Project setup

ghcup install ghc --set recommended     # ghcup manages GHC, cabal, HLS, stack
ghcup install cabal latest
ghcup install hls latest
cabal init --interactive                 # or: stack new myproj
cabal build all && cabal test all
cabal run myproj -- --flag
cabal repl                               # :r reload, :t type, :i info, :doc
ChooseWhen
cabal (default)Most new projects; Hackage-native; cabal.project + cabal freeze for pinning
stackTeam already uses it, or you want curated Stackage LTS snapshots out of the box
Nix (haskell.nix / nixpkgs)Org already standardizes on Nix

Keep one GHC version per project and pin it (with-compiler: ghc-9.x.y in cabal.project, or the Stackage resolver). HLS must match the GHC version exactly.

-- myproj.cabal (excerpt)
common shared
  default-language: GHC2021            -- GHC2024 on GHC >= 9.10
  default-extensions: OverloadedStrings DerivingStrategies LambdaCase StrictData
  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-uni-patterns
               -Wincomplete-record-updates -Wredundant-constraints -Wunused-packages

library
  import: shared
  hs-source-dirs: src
  exposed-modules: Billing.Invoice
  build-depends: base >=4.18 && <5, text, containers, mtl

test-suite spec
  import: shared
  type: exitcode-stdio-1.0
  main-is: Spec.hs
  hs-source-dirs: test
  build-depends: base, myproj, hspec, QuickCheck

Add -Werror in CI only (cabal build --ghc-options=-Werror), not in the package file uploaded to Hackage.

Modelling with types

data PaymentState
  = Pending
  | Paid !ReceiptId !UTCTime
  | Failed !FailureReason
  deriving stock (Eq, Show)

data FailureReason = CardDeclined | InsufficientFunds | ProviderError !Text
  deriving stock (Eq, Show)

newtype ReceiptId = ReceiptId Text
  deriving newtype (Eq, Ord, Show)

-- Smart constructor: the only way to build a valid Email
newtype Email = Email Text
mkEmail :: Text -> Either Text Email
mkEmail t
  | "@" `T.isInfixOf` t = Right (Email (T.toLower t))
  | otherwise           = Left ("invalid email: " <> t)
  • Export the type but not its constructor (module M (Email, mkEmail)) to enforce the invariant.
  • Sum types over boolean flags: data Visibility = Public | Private instead of Bool.
  • Use DerivingStrategies to say whether an instance is stock, newtype, or anyclass.
  • Records: enable OverloadedRecordDot (user.name) and DuplicateRecordFields if field names repeat across types; avoid partial record fields on multi-constructor types.

Maybe, Either, and composing failures

import Text.Read (readMaybe)

parseAge :: Text -> Either Text Int
parseAge t = case readMaybe (T.unpack t) of
  Nothing -> Left ("not a number: " <> t)
  Just n
    | n < 0 || n > 150 -> Left ("out of range: " <> t)
    | otherwise        -> Right n

mkUser :: Text -> Text -> Either Text User
mkUser rawEmail rawAge = do         -- Either is a Monad: first Left short-circuits
  email <- mkEmail rawEmail
  age   <- parseAge rawAge
  pure User {email, age}            -- NamedFieldPuns
NeedUse
Optional valueMaybe a + maybe, fromMaybe, mapMaybe
Failure with a reasonEither e a; e is a sum type in libraries, Text in scripts
Collect all validation errorsValidation (from validation / either packages) via Applicative
Failure inside IOExceptT e IO a locally, or throw typed exceptions at the edge
Many independent effectstraverse, sequenceA, for_
  • traverse parseAge inputs :: Either Text [Int] fails on the first bad input.
  • when, unless, for_, traverse_ (from Data.Foldable) for effects without results.

Typeclasses

  • Define a typeclass only when you have several real instances and laws to state. A record of functions is often simpler for “interfaces” (dependency injection, mocking).
  • Lawful instances: Semigroup/Monoid for combinable data (<>, mempty), Functor, Foldable, Traversable via deriving stock.
  • Orphan instances (instance defined in neither the class’s nor the type’s module) cause conflicts; wrap in a newtype instead.
  • Show is for debugging; write explicit rendering functions (renderInvoice :: Invoice -> Text) for user-facing output.

Application structure

SizePattern
Scripts, small toolsPlain IO with pure helpers
ServicesReaderT Env IO (“ReaderT pattern”): config, logger, DB pool in Env; IORef/TVar for state
Library needing abstract effectsmtl-style classes (MonadReader, custom MonadDB), or an effect library (effectful, bluefin) when the team agrees
data Env = Env { envPool :: Pool Connection, envLog :: LogAction IO Text }

newtype App a = App (ReaderT Env IO a)
  deriving newtype (Functor, Applicative, Monad, MonadIO, MonadReader Env)

runApp :: Env -> App a -> IO a
runApp env (App m) = runReaderT m env

Avoid StateT over IO for shared mutable state in concurrent code: it is lost on exceptions and not shared across threads. Use IORef or STM (TVar) inside Env.

Exceptions and resources

  • Pure code returns Either/Maybe; IO code can throw. Catch at boundaries with try @SomeException sparingly; catch specific types (IOException, your own).
  • Use bracket, finally, or withFile-style functions for resources; never manual open/close pairs.
  • Asynchronous exceptions (killThread, timeouts) must not be swallowed: use safe-exceptions or unliftio whose catch rethrows async exceptions.
  • error and undefined are for impossible states only; prefer an Either or a total type.

Laziness, strictness, and performance

  • Space leaks usually come from lazy accumulators. Use foldl' (never foldl), strict fields, Data.Map.Strict, modifyIORef', and bang patterns on loop accumulators.
  • length, nub (O(n²)), and ++ in a left-nested loop are common slow spots; use Data.Set/Data.HashSet for dedupe and Data.Text.Builder or Data.ByteString.Builder for output.
  • String concatenation is a linked list of chars; switch to Text before optimizing anything else.
  • Profile before guessing: cabal run --enable-profiling -- +RTS -p -hT -RTS, read the .prof file, view heap profiles with eventlog2html.
  • +RTS -s prints GC stats; a high GC percentage points to allocation or leak issues.
  • Use -O1 (cabal default) for release; -O2 only with measured benefit.

Concurrency

  • async package: concurrently, mapConcurrently, race, withAsync — they cancel siblings on exception. Avoid bare forkIO in application code.
  • STM (TVar, atomically) for shared state with invariants across several variables.
  • Build with -threaded and run with +RTS -N for multicore.

Testing

-- test/Spec.hs
import Test.Hspec
import Test.QuickCheck

main :: IO ()
main = hspec $ do
  describe "parseAge" $ do
    it "rejects negative numbers" $
      parseAge "-1" `shouldSatisfy` isLeft
    it "round-trips valid ages" $ property $ \(n :: Int) ->
      (n >= 0 && n <= 150) ==> parseAge (T.pack (show n)) === Right n
  • QuickCheck properties for parsers, serializers (decode . encode == Just), and algebraic laws; hspec examples for business rules.
  • hspec-discover for automatic test discovery in larger suites.
  • doctest or cabal-docspec keeps Haddock examples honest.

Reading GHC errors

Read the first error only, fix it, rebuild. Later errors are often consequences. For a catalog of the common messages (“Couldn’t match type”, “Ambiguous type variable”, “No instance for”, rigid type variables, monomorphism restriction) with cause and fix, read references/ghc-errors.md when the error text is not obvious.

Gotchas

  • foldl builds thunks; foldl' from Data.List (re-exported by Prelude in base ≥ 4.20).
  • Data.Map (lazy) values accumulate thunks under repeated insertWith; import Data.Map.Strict.
  • show on Text adds quotes; use the text itself or T.unpack for display.
  • read crashes on bad input; readMaybe from Text.Read.
  • OverloadedStrings can make literals ambiguous in polymorphic code; annotate ("x" :: Text).
  • lazy IO (readFile, getContents) can close handles late or read after a write; use strict Data.Text.IO / Data.ByteString reads or streaming libraries.
  • Integer is arbitrary precision; Int overflows silently. Pick deliberately for money and IDs.

Verification

  1. cabal build all --ghc-options=-Werror (or stack build --pedantic) succeeds.
  2. hlint . clean, or justified {- HLINT ignore -} annotations.
  3. cabal test all passes; QuickCheck properties run with enough cases (--qc-max-success=1000 for parsers).
  4. Format with fourmolu or ormolu (whichever the project uses) in check mode.
  5. For performance work: compare +RTS -s totals and profiles before and after.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/ghc-errors.md 119 lines · 5.6 KB
# GHC errors decoded

Read the first error, find the expression GHC points at, and compare "Expected" with
"Actual". Recent GHCs print an error code like `[GHC-83865]`; look it up at
errors.haskell.org for the official explanation and examples.

## Couldn't match expected type ‘X’ with actual type ‘Y’

The workhorse error. GHC inferred `Y` for an expression where context requires `X`.

| Expected / Actual | Usual cause | Fix |
|---|---|---|
| `Text` / `[Char]` | String literal without `OverloadedStrings`, or a `String` function | Enable `OverloadedStrings`; convert with `T.pack` / `T.unpack` |
| `IO a` / `a` | Returned a pure value as the last line of a `do` block | `pure x` (or `return x`) |
| `a` / `IO a` | Used an IO action where its result was needed | Bind it first: `x <- action` |
| `Maybe a` / `a` | Forgot to wrap | `Just x`, or change the signature |
| `a` / `Maybe a` | Used a `Maybe` as if it were a value | Pattern match, `maybe`, `fromMaybe` |
| `[a]` / `a` | `:` vs `++` mix-up | `x : xs` prepends one element, `xs ++ ys` joins lists |
| `a -> b` / `b` | Missing argument (partial application) | Supply the argument, or check argument order |
| `b` / `a -> b` | Too many arguments, often because of a missing `$` or parentheses | `f (g x)` or `f $ g x` |
| `Int` / `Integer` | Defaulting picked `Integer` | Annotate the literal or binding |
| `Either e a` / `Either e' a` | Two error types in one `do` block | Unify with a sum error type, or `first Wrap` from `Data.Bifunctor` |

Read the "In the expression / In an equation for" lines: they tell you which sub-expression
GHC was checking. Add a type annotation to an intermediate `let` to localize the mismatch.

## Couldn't match type ‘a’ with ‘Int’ — ‘a’ is a rigid type variable

Your signature promises the function works for any `a`, but the body uses `a` as a
specific type.

```haskell
-- Wrong: promises any a, but adds 1
inc :: a -> a
inc x = x + 1
```

Fix: constrain (`inc :: Num a => a -> a`) or make it concrete (`Int -> Int`). If the
variable is in a local signature that refers to an outer one, enable
`ScopedTypeVariables` (on in GHC2021) and add `forall a.` to the outer signature.

## No instance for (Show X) / (Eq X) / (Num [Char])

- Missing derivable instance: add `deriving stock (Show, Eq)`.
- `No instance for (Num [Char])` or `(Num Text)`: a number used where a string is
  expected, or vice versa; usually an argument-order bug.
- `No instance for (MonadIO m)`: calling `liftIO` in a polymorphic monad; add the
  constraint or use a concrete `App`/`IO`.
- For types you do not own, write a newtype wrapper and the instance on it, not an orphan.

## Ambiguous type variable ‘a0’ arising from a use of ‘show’ / ‘read’ / ‘fromIntegral’

GHC cannot decide a type because nothing fixes it.

```haskell
print (read "5")          -- read what?
print (read "5" :: Int)   -- fixed
```

Common with `OverloadedStrings` (`"x"` could be `Text`, `String`, `ByteString`), `mempty`,
`fromIntegral` chains, and `length` on `Foldable`. Annotate one point:
`(x :: Text)`, or use `TypeApplications`: `read @Int "5"`.

## Could not deduce (Ord k) arising from a use of ‘Map.insert’

A polymorphic function calls something with a constraint that its own signature lacks.
Add it: `insertAll :: Ord k => [(k, v)] -> Map k v -> Map k v`.

## Non-exhaustive patterns / Pattern match(es) are non-exhaustive

A warning at compile time with `-Wall` (keep it on); a crash at runtime otherwise.
Handle every constructor, or restructure with a total type (`NonEmpty`). The message
lists the missing patterns.

## Variable not in scope / Not in scope: data constructor

- Missing import, or the module exports the type but not the constructor (intentional for
  smart constructors; use the provided function).
- Qualified import mismatch: `import qualified Data.Map.Strict as Map` then `Map.insert`.
- HLS "import suggestion" code actions fix most of these.

## Occurs check: cannot construct the infinite type: a ~ [a]

You used a value both as an element and as a list of that element, often `x : x` instead
of `x : xs`, or a recursive call missing a constructor. Check each recursive call's type.

## The monomorphism restriction / defaulting surprises

A top-level binding without a signature and without arguments (`plus = (+)`) is given a
single concrete type. Add a type signature (best) or eta-expand (`plus a b = a + b`).

## Couldn't match type ‘m’ with ‘IO’ in a do block mixing monads

Each `do` block runs in exactly one monad. Lift:
- IO inside `App`/`ReaderT`: `liftIO action`.
- `Either` inside IO: `either throwIO pure =<< ...`, or run the pure part first and
  `case` on the result.
- `Maybe` inside IO: `MaybeT` or pattern match.

## Kind errors: Expected kind ‘* -> *’, but ‘X’ has kind ‘*’

You used a fully applied type where a type constructor was expected (or the reverse):
`instance Functor (Tree a)` should be `instance Functor Tree`. In `deriving newtype`
lists, check the class expects the newtype's kind.

## Overlapping / duplicate instance declarations

Two instances match the same type, usually an orphan from a dependency plus your own.
Remove yours or wrap the type in a newtype. Avoid `{-# OVERLAPPING #-}` unless you
control all instances.

## Linker / build errors

| Message | Fix |
|---|---|
| `cannot satisfy -package-id` / hidden package `X` | Add `X` to `build-depends` of that component |
| `Could not resolve dependencies` | Relax upper bounds, `cabal update`, or `--allow-newer=pkg` temporarily; record why |
| HLS shows errors cabal does not | HLS built for a different GHC; reinstall matching HLS via ghcup |
| `Module ‘X’ is not exposed` | Add to `exposed-modules` or `other-modules` |

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc