Practical Haskell skill for Codex
Haskell that builds clean and has no partial functions. Codex orients in a Haskell project by reading the cabal or Stack files and GHC version, then keeps logic pure and IO at the edges, replaces partial functions with total ones, and uses Text and strict containers to avoid space leaks. GHC type errors are decoded with a lookup table and typed holes rather than guessed annotations, and changes are checked with -Wall, HLint, hspec and QuickCheck.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- haskell-basics Installs to ~/.agents/skills/haskell-basics/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install haskell-basics The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project haskell-basics Installs to .agents/skills/haskell-basics/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/haskell-basics.zipunzip -o haskell-basics.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) haskell-basics PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $haskell-basics, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Reads, writes and fixes practical Haskell with GHC, cabal or Stack - project layout, Text over String, Maybe and Either error handling, records and deriving, type classes, IO and the ReaderT app pattern, strictness and space leaks, aeson JSON, hspec and QuickCheck tests - and decodes common GHC type errors with typed holes and GHCi. Use when working in a Haskell codebase for the first time, fixing build or type errors, adding a feature to a cabal or Stack project, or writing small production tools in Haskell.
Use it when
- Working in an unfamiliar cabal or Stack project
- GHC type errors or build failures need decoding
- Adding features, tests or JSON handling to Haskell code
- Memory grows over time in a long-running Haskell program
Use something else when
- The project is Scala or another JVM functional language: use scala-3
- You want generic property-based testing guidance across languages: use property-based-testing
How to check the result
The build is -Wall clean, cabal test or stack test passes, and HLint plus the repo's formatter report no new issues on changed modules.
The mistake it prevents
Using foldl, lazy Map or lazy IORef updates on accumulating state, building thunks until memory runs out.
Practical Haskell compiles with -Wall clean, keeps IO at the edges and logic pure,
uses Text, strict maps and explicit error types, has no partial functions on input
data, and is checked with tests and HLint. Type errors are resolved by reading what GHC
expected versus what it got, not by adding annotations until it compiles.
Orient first
- Read
AGENTS.md,*.cabalorpackage.yaml(hpack),cabal.project,stack.yaml, and.hlint.yaml/fourmolu.yaml. - Tool: if
stack.yamlexists use Stack (stack build,stack test); otherwise cabal (cabal build all,cabal test all). Do not mix them in one project. - Note the GHC version (
ghc --version, the Stack snapshotresolver/snapshot, orwith-compilerincabal.project) and thedefault-language(GHC2021,GHC2024orHaskell2010plus extensions). - First builds download the package index and dependencies (and Stack may download GHC); that needs network access and can take a long time. Ask before running.
- Map the code:
exposed-modulesin the library stanza,main-isfor executables,hs-source-dirsfor where files live.
Daily commands
| Task | cabal | Stack |
|---|---|---|
| Build | cabal build all | stack build |
| Test | cabal test all --test-show-details=direct | stack test |
| REPL | cabal repl lib:mypkg | stack ghci |
| Fast reload loop | ghcid --command "cabal repl" | ghcid --command "stack ghci" |
| Run | cabal run myexe -- args | stack exec myexe -- args |
| Pin deps | cabal freeze / index-state in cabal.project | snapshot in stack.yaml |
In GHCi: :t expr (type), :i Name (info and instances), :r (reload),
:set -Wall.
Idioms
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DerivingStrategies, DeriveAnyClass, DeriveGeneric #-}
module Billing.Invoice
( Invoice (..)
, InvoiceError (..)
, mkInvoice
, total
) where
import Data.Aeson (FromJSON, ToJSON)
import Data.Text (Text)
import qualified Data.Text as T
import GHC.Generics (Generic)
data InvoiceError
= EmptyCustomer
| NegativeAmount Int
deriving stock (Eq, Show)
data Invoice = Invoice
{ invoiceCustomer :: !Text
, invoiceLines :: ![Int] -- amounts in cents
}
deriving stock (Eq, Show, Generic)
deriving anyclass (FromJSON, ToJSON)
-- Smart constructor: the only way to build a valid Invoice from untrusted input
mkInvoice :: Text -> [Int] -> Either InvoiceError Invoice
mkInvoice customer amounts
| T.null (T.strip customer) = Left EmptyCustomer
| (bad : _) <- filter (< 0) amounts = Left (NegativeAmount bad)
| otherwise = Right (Invoice customer amounts)
total :: Invoice -> Int
total = sum . invoiceLines
- Export lists on every module; hide constructors (
Invoicewithout(..)) when a smart constructor must be used. Textfor text,ByteStringfor bytes,Stringonly for tiny or legacy APIs.Data.Map.StrictandData.IORef.modifyIORef'(with the prime), and strict fields (!) on records that accumulate, to avoid thunk build-up.foldl', neverfoldl, for accumulating sums and maps.Maybewhen absence is normal,Either Err awhen the caller needs to know why, exceptions only for truly exceptional IO failures.
Avoid partial functions
| Partial | Total alternative |
|---|---|
head xs, tail xs | pattern match, Data.List.NonEmpty, or listToMaybe |
fromJust m | maybe, fromMaybe, or a case |
read s | readMaybe s (Text.Read) |
xs !! i | drop i xs with a match, or Data.Vector.!? |
Map.! k | Map.lookup k |
GHC 9.8+ warns on head/tail (-Wx-partial); treat those warnings as real.
IO structure
Keep a pure core and a thin IO shell. For applications with config, logging and DB handles, the ReaderT pattern is the common default:
data Env = Env { envLogger :: Text -> IO (), envDb :: Connection }
newtype App a = App { runApp :: ReaderT Env IO a }
deriving newtype (Functor, Applicative, Monad, MonadIO, MonadReader Env)
logInfo :: Text -> App ()
logInfo msg = do
logger <- asks envLogger
liftIO (logger msg)
Follow whatever effect style the codebase already uses (mtl classes, ReaderT, effectful, polysemy); do not introduce a second one.
Exceptions: use bracket / finally for resources, try @IOException (with
TypeApplications, on by default in GHC2021) for expected IO failures, and do not catch
SomeException without rethrowing async exceptions (use safe-exceptions or
unliftio helpers if the project has them).
Reading GHC errors
| Message | Usually means |
|---|---|
Couldn't match expected type 'Text' with actual type '[Char]' | missing OverloadedStrings, or use T.pack |
Couldn't match type 'IO a' with 'a' | used an IO action as a value; bind it with <- in do |
No instance for (Show X) / (FromJSON X) | add a deriving clause or write the instance |
Ambiguous type variable a0 | GHC cannot pick a type (often read, show . read, numeric literals); annotate or use @Type |
Couldn't match type 'a' with 'Int', 'a' is a rigid type variable | your signature promises any a but the code needs a specific type; fix the signature |
Variable not in scope | missing import, or the module does not export it |
parse error on input | indentation in do, where, case or let blocks |
Use typed holes to let GHC tell you what fits: write _ (or _name) where you are
stuck and read the “Found hole” message with the expected type and valid fits.
Testing
import Test.Hspec
import Test.QuickCheck
spec :: Spec
spec = describe "mkInvoice" $ do
it "rejects an empty customer" $
mkInvoice " " [100] `shouldBe` Left EmptyCustomer
it "total is the sum of non-negative lines" $
property $ \(xs :: [NonNegative Int]) ->
fmap total (mkInvoice "acme" (map getNonNegative xs))
== Right (sum (map getNonNegative xs))
Property tests fit pure code well; use them for parsers (round-trip
decode . encode == Just), and unit tests for specific edge cases.
Verification
- Build with
-Wall(inghc-options); no new warnings.-Werrorbelongs in CI flags, not in the published.cabalfile. - Tests pass (
cabal test allorstack test). -
hlint .reports no new hints on changed modules (or each ignored hint is justified). - Formatter the repo uses (
fourmolu,ormolu,stylish-haskell) leaves no diff. - For long-running code, check memory with
+RTS -son a realistic input when folds or accumulators changed.
Gotchas
- Lazy IO (
readFilefromPrelude) can close handles late or leak; useData.Text.IO/ByteStringstrict reads, or streaming libraries for big files. lengthonTextis O(n);T.lengthin loops adds up.showonTextadds quotes; use the value directly orT.unpack.- Orphan instances (instance defined away from both the class and the type) cause conflicts; put instances with the type, or use a newtype.
- Changing an exported type or adding a constructor is a breaking change under the PVP; bump the major version for libraries.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Practical Haskell"
short_description: "Haskell that builds clean and has no partial functions"
default_prompt: "$haskell-basics Explain this GHC type error and fix it properly" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.