# Haskell Type Design

> Design Haskell domain types and records idiomatically. Covers type-driven design (domain types first, functions second), newtypes for primitives, "parse don't validate", phantom types for state machines, and record conventions (positional vs named, field prefixes, NamedFieldPuns, when to reach for optics). Use when modeling a domain in Haskell, defining records or sum types, reviewing type design, or explaining the type-first mindset.

- Skill: `ivelten/haskell-type-design` (Agent Skill)
- Install (CLI): `npx skillmds@latest add ivelten/haskell-type-design`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ivelten/haskell-type-design/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ivelten (https://skillmd.com/u/ivelten)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/ivelten/haskell-type-design

---


# Haskell Type Design

Write the types first, then the function falls out. This is the central discipline: the type signature constrains the implementation so tightly that wrong code usually doesn't type-check.

## Type-Driven Design

1. **Domain first, functions second.** Sketch the algebraic data types for the problem before any logic. Sum types (`data Status = Pending | Approved | Rejected Reason`) replace state-tracking booleans and string-typed enums.

2. **Newtypes for every primitive that has meaning.** `newtype UserId = UserId Int64` and `newtype Email = Email Text` cost nothing at runtime and prevent entire classes of bugs at the type level. Use `deriving newtype` for the underlying instances you actually want exposed:

   ```haskell
   newtype UserId = UserId Int64
     deriving stock   (Show, Eq, Ord)
     deriving newtype (FromJSON, ToJSON)
   ```

3. **Make functions total.** No `head`, no `fromJust`, no `error` in business logic. Return `Maybe a` or `Either MyError a`. The `safe` package provides `headMay` etc. if you want it.

4. **Parse, don't validate.** A validation function returns `Bool`; a parser returns a *narrower type* that carries proof of validity.
   - Wrong: `validateEmail :: Text -> Bool`
   - Right: `parseEmail :: Text -> Either EmailError Email`

   Once you hold an `Email`, you never need to check it again — the type carries the proof. "Make illegal states unrepresentable."

5. **Phantom types for state machines.** When state transitions must be enforced at compile time, reach for phantom type parameters with `DataKinds`:

   ```haskell
   data ConnState = Open | Closed
   data Connection (s :: ConnState) = Connection { ... }

   openConn  :: ConnConfig -> IO (Connection 'Open)
   closeConn :: Connection 'Open -> IO (Connection 'Closed)
   send      :: Connection 'Open -> ByteString -> IO ()
   ```

   `send` on a `Connection 'Closed` is a *compile error*, not a runtime exception.

## Records

Stick to the classic Haskell record style. It's verbose in one specific way (field names need a prefix to avoid clashes) but everything else about it is friction-free, works in every tutorial and library you'll read, and crucially keeps **field accessors as first-class functions** that compose normally — a property that `NoFieldSelectors` destroys.

### Two shapes

**Positional, for small types with obvious field semantics:**

```haskell
data Point     = Point Double Double
data Pair a b  = Pair a b
data Range     = Range Int Int  -- low, high
```

Use this when there are 2–3 fields and the order is unambiguous. Destructure positionally: `\(Point x y) -> ...`.

**Named records, for everything else:**

```haskell
data User = User
  { userId    :: !UserId
  , userEmail :: !Email
  , userName  :: !Text
  } deriving stock (Eq, Show)

data Product = Product
  { productId    :: !ProductId
  , productName  :: !Text
  , productPrice :: !Money
  } deriving stock (Eq, Show)
```

Note the prefix on every field. This is the deliberate trade-off: a few extra characters per field name in exchange for zero ambiguity, zero extensions, and accessors that compose:

```haskell
-- Accessors are just functions. They compose, they map, they sort.
emails  = map userEmail users
sorted  = sortBy (comparing userName) users
totals  = sum (map productPrice cart)
```

The `!` strict-field annotations are intentional — see `haskell-quality-gates` for why strict-by-default avoids common space leaks.

### Naming convention for field prefixes

Pick one and stick to it across the project:

- **Camel-cased type name + field**: `User { userId, userEmail }`, `Product { productId, productName }`. Most common convention and what you'll see in published Haskell code. **Default to this.**
- **Short abbreviation** when the type name is long and used heavily: `OrderLineItem { oliQuantity, oliPrice }`. Use sparingly — abbreviations are project-private vocabulary.
- **Underscore prefix** (`_userId`, `_userEmail`) **only if you adopt `lens` or `optics` from the start**. `makeLenses` strips the underscore to generate the lens names, so `_userId` becomes the lens `userId`.

### Pattern matching on records

Use `NamedFieldPuns` per-module when destructuring named records — it removes the noise of `User { userId = uid, userEmail = e }`:

```haskell
{-# LANGUAGE NamedFieldPuns #-}

greet :: User -> Text
greet User{userName, userEmail} =
  "Hello, " <> userName <> " (" <> coerce userEmail <> ")"
```

`NamedFieldPuns` only affects local pattern matches; it doesn't change field selectors or introduce any ambiguity. Safe to enable broadly.

### When you really need deep access or update

For deeply nested reads, multi-level updates, or working with complex state, reach for the [`optics`](https://hackage.haskell.org/package/optics) library (preferred over `lens` — cleaner errors, no operator soup). Don't try to hand-roll nested record updates; the `user { profile = (profile user) { email = ... } }` syntax gets ugly fast and optics exists exactly for this.

## Related

- Domain errors are themselves a type-design concern — see `haskell-domain-errors` for the error-modeling pattern.

