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.
- name
haskell-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
- Functional programming on the JVM with Cats Effect or ZIO — use scala-functional
- Writing generators and invariants in Python or TypeScript — use property-based-testing
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.
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,maximumon possibly-empty lists. Use pattern matching,Data.List.NonEmpty,listToMaybe,readMaybe. Textfor human text,ByteStringfor bytes and wire data;Stringonly for tiny scripts or APIs that demand it. Turn onOverloadedStrings.- Strict fields on data types (
!Int) orStrictDataper module unless you need laziness deliberately. newtypefor every domain identifier and unit (newtype UserId = UserId Int).- Pure core, IO shell: business logic takes and returns plain data;
mainand 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
| Choose | When |
|---|---|
| cabal (default) | Most new projects; Hackage-native; cabal.project + cabal freeze for pinning |
| stack | Team 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 | Privateinstead ofBool. - Use
DerivingStrategiesto say whether an instance isstock,newtype, oranyclass. - Records: enable
OverloadedRecordDot(user.name) andDuplicateRecordFieldsif 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
| Need | Use |
|---|---|
| Optional value | Maybe a + maybe, fromMaybe, mapMaybe |
| Failure with a reason | Either e a; e is a sum type in libraries, Text in scripts |
| Collect all validation errors | Validation (from validation / either packages) via Applicative |
| Failure inside IO | ExceptT e IO a locally, or throw typed exceptions at the edge |
| Many independent effects | traverse, sequenceA, for_ |
traverse parseAge inputs :: Either Text [Int]fails on the first bad input.when,unless,for_,traverse_(fromData.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/Monoidfor combinable data (<>,mempty),Functor,Foldable,Traversableviaderiving stock. - Orphan instances (instance defined in neither the class’s nor the type’s module) cause conflicts; wrap in a newtype instead.
Showis for debugging; write explicit rendering functions (renderInvoice :: Invoice -> Text) for user-facing output.
Application structure
| Size | Pattern |
|---|---|
| Scripts, small tools | Plain IO with pure helpers |
| Services | ReaderT Env IO (“ReaderT pattern”): config, logger, DB pool in Env; IORef/TVar for state |
| Library needing abstract effects | mtl-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 withtry @SomeExceptionsparingly; catch specific types (IOException, your own). - Use
bracket,finally, orwithFile-style functions for resources; never manual open/close pairs. - Asynchronous exceptions (
killThread, timeouts) must not be swallowed: usesafe-exceptionsorunliftiowhosecatchrethrows async exceptions. errorandundefinedare for impossible states only; prefer anEitheror a total type.
Laziness, strictness, and performance
- Space leaks usually come from lazy accumulators. Use
foldl'(neverfoldl), 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; useData.Set/Data.HashSetfor dedupe andData.Text.BuilderorData.ByteString.Builderfor output.Stringconcatenation is a linked list of chars; switch toTextbefore optimizing anything else.- Profile before guessing:
cabal run --enable-profiling -- +RTS -p -hT -RTS, read the.proffile, view heap profiles witheventlog2html. +RTS -sprints GC stats; a high GC percentage points to allocation or leak issues.- Use
-O1(cabal default) for release;-O2only with measured benefit.
Concurrency
asyncpackage:concurrently,mapConcurrently,race,withAsync— they cancel siblings on exception. Avoid bareforkIOin application code.- STM (
TVar,atomically) for shared state with invariants across several variables. - Build with
-threadedand run with+RTS -Nfor 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-discoverfor automatic test discovery in larger suites.doctestorcabal-docspeckeeps 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
foldlbuilds thunks;foldl'fromData.List(re-exported by Prelude in base ≥ 4.20).Data.Map(lazy) values accumulate thunks under repeatedinsertWith; importData.Map.Strict.showonTextadds quotes; use the text itself orT.unpackfor display.readcrashes on bad input;readMaybefromText.Read.OverloadedStringscan make literals ambiguous in polymorphic code; annotate ("x" :: Text).lazy IO(readFile,getContents) can close handles late or read after a write; use strictData.Text.IO/Data.ByteStringreads or streaming libraries.Integeris arbitrary precision;Intoverflows silently. Pick deliberately for money and IDs.
Verification
cabal build all --ghc-options=-Werror(orstack build --pedantic) succeeds.hlint .clean, or justified{- HLINT ignore -}annotations.cabal test allpasses; QuickCheck properties run with enough cases (--qc-max-success=1000for parsers).- Format with
fourmoluorormolu(whichever the project uses) in check mode. - For performance work: compare
+RTS -stotals 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
# 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.