Why Haskell Needs Specific AI Coding Rules
Haskell is the purest of mainstream functional languages — side effects are tracked in the type system, laziness is the default evaluation strategy, and the type checker is remarkably powerful. AI assistants, trained predominantly on imperative languages, generate Haskell that looks like translated Python: IO everywhere, partial functions, String instead of Text, and hand-rolled recursion instead of higher-order functions.
The gap between 'Haskell that compiles' and 'Haskell that a Haskell developer would write' is wider than in any other language. The type system guarantees safety but not idiomacy. Your CLAUDE.md bridges this gap by encoding the conventions that separate beginner Haskell from production Haskell.
These rules target GHC-based Haskell projects. Adjust for your specific ecosystem (Servant, Yesod, IHP) and preferred extensions.
Rule 1: Purity and IO Isolation
The rule: 'Keep functions pure by default. Business logic must not live in IO — extract it into pure functions that take data and return data. IO belongs only at the edges: main, request handlers, database access. If a function's type signature includes IO but its logic doesn't need side effects, refactor it to be pure.'
For the IO boundary: 'Think of IO as an outer shell: IO reads input, calls pure functions, and writes output. The pure core contains all business logic, validation, transformation, and decision-making. This separation makes code testable — pure functions test without IO, mock, or setup.'
This is the foundational Haskell rule. AI assistants put IO in everything because that's the path of least resistance. The rule forces the AI to think about what's actually effectful and what's pure computation.
Business logic must not live in IO. Think of your app as a pure core (all logic, validation, transforms) wrapped in an IO shell (reads input, calls core, writes output). This makes everything testable.
Rule 2: Algebraic Data Types and Pattern Matching
The rule: 'Model domain concepts with algebraic data types (ADTs). Use sum types (data Result = Success Data | Failure Error) for values that can be one of several variants. Use product types (data User = User { name :: Text, age :: Int }) for compound data. Pattern match exhaustively — enable -Wall to catch missing patterns. Never use undefined or error for cases you haven't handled yet — define all variants explicitly.'
For newtype wrappers: 'Use newtype for zero-cost type distinctions: newtype UserId = UserId Int, newtype Email = Email Text. This prevents accidentally passing an Int where a UserId is expected — the compiler catches it, with zero runtime overhead.'
For records: 'Use OverloadedRecordDot (GHC 9.2+) for record access. Use lens or optics for complex nested record updates. Define record fields with the Has pattern or use DuplicateRecordFields + OverloadedRecordDot for shared field names.'
- Sum types for variants — data Shape = Circle Double | Rectangle Double Double
- Product types for compound data — records with named fields
- newtype for zero-cost type safety — UserId, Email, Amount
- Exhaustive pattern matching — -Wall catches missing cases
- Never undefined or error for unhandled cases — define all variants
Rule 3: Monadic Composition and Type Classes
The rule: 'Use do-notation for monadic composition — keep it readable. Use the Functor/Applicative/Monad hierarchy appropriately: fmap for simple transforms, <*> for applicative combinations, >>= for sequencing with dependencies. Prefer applicative style over monadic when the computation doesn't depend on previous results — it's more parallelizable and clearer.'
For common monads: 'Use Maybe for optional values (never null patterns). Use Either for computations that can fail with an error. Use Reader for environment/configuration threading. Use State sparingly — prefer explicit parameter passing. Use ExceptT for effect stacks with errors.'
For type classes: 'Define custom type classes only when you need ad-hoc polymorphism across types that don't share a structure. Most of the time, a plain function with a type constraint works better. Derive standard instances (Show, Eq, Ord, Generic, FromJSON, ToJSON) with deriving strategies.'
Rule 4: Text, Strictness, and Performance
AI assistants default to String (which is [Char] — a linked list of characters) because it's the simplest text type. In production Haskell, String is almost never what you want — it's slow and memory-hungry.
The rule: 'Use Text (from Data.Text) for all string data — never String in production code. Use ByteString for binary data. Enable OverloadedStrings extension. Use strict Text, not lazy — unless you're processing streaming data. Use Text.pack only at boundaries — keep data as Text throughout.'
For strictness: 'Use strict data types by default: ! annotations on record fields, {-# LANGUAGE StrictData #-} pragma, or -XStrictData GHC flag. Lazy evaluation is powerful but causes space leaks in long-running applications. Be strict by default, lazy by choice.'
String is [Char] — a linked list of characters. It's slow and memory-hungry. Use Text for all string data in production. Enable OverloadedStrings and never look back.
Rule 5: Testing with Hspec and QuickCheck
The rule: 'Use Hspec for unit and integration tests. Use QuickCheck for property-based testing — it's Haskell's superpower. Define Arbitrary instances for domain types. Test pure functions with QuickCheck properties first, Hspec examples second. Test IO at the boundary using mock type classes or golden tests.'
For property-based testing: 'Express properties as universal truths about your functions: prop_reverseReverse xs = reverse (reverse xs) === xs. Use forAll for custom generators. Properties catch edge cases that example-based tests miss — they're especially valuable for parsers, serializers, and mathematical operations.'
For test organization: 'Mirror the source directory structure in test/. Use Hspec's describe/it for organizing tests. Use before/after for setup/teardown. Use shouldBe, shouldReturn, shouldThrow for assertions.'
Property-based testing is Haskell's superpower. Test pure functions with QuickCheck properties first, Hspec examples second. Properties catch edge cases that examples miss — especially for parsers and serializers.
Complete Haskell Rules Template
Consolidated template for Haskell teams using GHC 9.2+.
- Pure functions by default — IO only at the edges (main, handlers, DB access)
- Algebraic data types for domain modeling — sum types, product types, newtypes
- Exhaustive pattern matching — -Wall -Werror, never undefined for missing cases
- Text not String — strict by default, OverloadedStrings enabled
- do-notation for monads — applicative when computation is independent
- Maybe for optional, Either for errors — ExceptT for effect stacks
- Hspec + QuickCheck — property tests for pure logic, golden tests for IO
- HLint for linting — Ormolu or Fourmolu for formatting — Cabal or Stack for builds