The Elements of
PureScript Style


“Omit needless words.”
— William Strunk Jr.

I. Let the compiler do the work

PureScript's compiler is not a gatekeeper — it is a collaborator. Every feature in this section exists to move knowledge out of your head and into the type system, where it can be checked, enforced, and relied upon. The habit to cultivate is simple: when you know something about your program, ask whether the compiler can know it too.

1. Make the compiler's knowledge your own

When your function must handle every case of a sum type, write it as a pattern match — and let the compiler confirm you covered them all.

layoutInfo :: LayoutType -> LayoutInfo
layoutInfo = case _ of
  TreeHorizontal -> { name: "Tree", description: "Reingold-Tilford" }
  TreeVertical   -> { name: "Tree (Top-Down)", description: "Vertical layout" }
  Pack           -> { name: "Circle Pack", description: "Nested circles" }
  ...

This is clear and the compiler checks exhaustiveness. Now consider the alternative, which appears in many codebases:

layoutInfo :: LayoutType -> LayoutInfo
layoutInfo lt = fromMaybe { name: "Unknown", description: "" } $ Map.lookup lt infoMap
  where
  infoMap = Map.fromFoldable
    [ Tuple TreeHorizontal { name: "Tree", description: "Reingold-Tilford" }
    , Tuple TreeVertical { name: "Tree (Top-Down)", description: "Vertical layout" }
    , Tuple Pack { name: "Circle Pack", description: "Nested circles" }
    ...
    ]

The Map version looks sophisticated but is worse in every way that matters. The compiler cannot check that every constructor appears in the map. The fromMaybe with a dummy value silently handles the missing case — exactly the bug you wanted the type system to prevent. And the function is no longer obviously total; the reader must verify the map's contents against the type to be sure.

Reach for Map when the keys are data. Use pattern matching when the cases are the definition.

A corollary: when you match on a closed ADT, spell out every constructor. Do not catch the remaining cases with a wildcard. Today's wildcard is tomorrow's silent bug — when a new constructor is added, the compiler will warn you about the missing case in an explicit match but will say nothing about a wildcard that swallows it. Your language server or LLM will happily generate the boilerplate; there is no excuse for _ -> on a type you control.

2. Avoid boolean blindness with ADTs

When a value can only be one of a known set of alternatives, represent it as a sum type — not as a String you validate, and not as a Boolean you remember the meaning of.

-- Structure-dependent: the type answers the question.
data Element = Sidebar | Toolbar | StatusBar
data Visibility = Visible | Hidden

setVisibility :: Element -> Visibility -> Effect Unit

-- The call site documents itself:
setVisibility Sidebar Visible
-- Convention-dependent: what does `true` mean?
setVisibility :: String -> Boolean -> Effect Unit

-- The call site is opaque:
setVisibility "sidebar" true

The String-and-Boolean version requires the reader to know (or discover) what "sidebar" and true mean in this context. The ADT version requires them to know nothing beyond the types. And when someone adds a new element or a third visibility state (Collapsed), every call site that needs updating will fail to compile.

The same principle applies to intermediate representations. If your program passes around a String that can be "left", "right", or "center", and somewhere validates it — that validation is an admission that the type is wrong.

Prefer:

data Alignment = Left | Right | Center

align :: Alignment -> HTML -> HTML
align = case _ of
  Left   -> ...
  Right  -> ...
  Center -> ...

Over:

align :: String -> HTML -> HTML
align s = case s of
  "left"   -> ...
  "right"  -> ...
  "center" -> ...
  _        -> ... -- what goes here?

3. Newtype what you mean

A type alias documents intent for the reader. A newtype enforces it through the compiler.

-- Newtypes. The compiler distinguishes them.
newtype NodeID = NodeID Int
newtype LinkID = LinkID Int

derive newtype instance Eq NodeID
derive newtype instance Ord NodeID

derive newtype instance Eq LinkID
derive newtype instance Ord LinkID

addEdge :: NodeID -> NodeID -> LinkID -> Graph -> Graph
-- addEdge linkId nodeId nodeId graph  -- type error
-- A type alias. The compiler sees Int everywhere.
type NodeID = Int
type LinkID = Int

addEdge :: NodeID -> NodeID -> LinkID -> Graph -> Graph
-- Nothing prevents: addEdge linkId nodeId nodeId graph

The runtime cost is zero — newtypes are erased during compilation. The development cost is small: a declaration, a constructor, and a few derived instances. The return is that an entire class of argument-transposition bugs becomes impossible.

The question to ask is not "do I need a newtype here?" but "would swapping this value with another value of the same underlying type be a bug?" If the answer is yes, the type alias is a comment where you needed a guard rail.

A common situation in a real codebase: NodeID appears as a type alias in six modules across two packages. Some modules use Int directly, some use the alias, and the compiler treats them identically. A newtype in one shared location would have caught the inconsistency at the point it was introduced.

4. Constructor order is semantic — put it to work

When you derive Ord for a sum type, PureScript orders the constructors from left to right as declared. This is not an implementation detail — it is a design decision.

data Severity = Info | Warning | Error

derive instance Eq Severity
derive instance Ord Severity

-- Now: Info < Warning < Error
-- sort [Error, Info, Warning] == [Info, Warning, Error]

The ordering is free, correct, and obvious to anyone reading the type declaration. Think of constructor order as a feature, not an accident. When you define a type whose values have a natural ordering — severity levels, lifecycle stages, priority tiers — put the constructors in that order and derive Ord. If the ordering you want does not match any natural declaration order, write the instance by hand, but know you are making an affirmative choice.

5. Let the compiler write the instances

The previous entry showed one derived instance. In practice you will derive many — and this is what makes newtypes and ADTs cheap. You get the behaviour you need without writing or maintaining the code.

For a newtype wrapping a type that already has instances, use derive newtype instance to lift them through:

newtype Score = Score Int

derive newtype instance Eq Score
derive newtype instance Ord Score
derive newtype instance Semiring Score

Score now supports ==, compare, +, and * — all delegating to Int's implementations. No code was written and none can be wrong.

For sum types, the compiler can derive Eq, Ord, Functor, Foldable, Traversable, and several others directly:

data Direction = North | East | South | West

derive instance Eq Direction
derive instance Ord Direction

For the full monad transformer pattern, newtype deriving eliminates what would otherwise be five repetitive instance declarations:

newtype AppM a = AppM (ReaderT Config (ExceptT AppError Aff) a)

derive newtype instance Functor AppM
derive newtype instance Apply AppM
derive newtype instance Applicative AppM
derive newtype instance Bind AppM
derive newtype instance Monad AppM

Each line delegates to the corresponding instance on the wrapped transformer stack. Without newtype deriving, each would be ten lines of manual lifting. With it, the compiler generates correct code that you never read, never test, and never debug.

6. Records multiply possibilities — use sums to limit them

Records are natural and familiar. You throw together a few fields, and you have a data structure. But every field you add multiplies the space of representable values, and most of those combinations are nonsense. If two fields can combine to represent an impossible state — that is a bug in your types, and you will spend the rest of the codebase defending against it.

-- Four states, each carrying exactly the data it needs. Nothing else is representable.
data Connection
  = Disconnected
  | Connecting Url
  | Connected Url Socket
  | Failed Url Error

The sum type makes the impossible states unrepresentable. You cannot construct a Disconnected with a socket because the constructor does not accept one. You cannot construct a Failed without an error because the constructor requires one. The defence is structural, not conventional. Compare with:

-- Four fields, each independent. How many combinations?
-- String × Maybe Url × Maybe Socket × Maybe Error — far too many.
type Connection =
  { status :: String          -- "disconnected" | "connecting" | "connected" | "failed"
  , url :: Maybe Url
  , socket :: Maybe Socket
  , error :: Maybe Error
  }

Can you have a "disconnected" connection with a socket? A "failed" connection with no error? A "connecting" connection with no URL? The type says yes. The domain says no. Every function that touches this record must defend against those impossible combinations — or trust that no code produces them. That trust is the bug.

This is one of the biggest ideas in typed functional programming. Smart constructors (next entry), newtypes, NonEmpty, phantom types — many entries in this guide are applications of the same principle. The habit to develop: whenever you define a type, ask what states it allows that should not exist. If the answer is "many," a sum type is waiting to be extracted.

7. Think about the state space of every type you define

The previous entry showed one specific fix — replacing a record with a sum type. This entry is the general principle behind it.

When you combine types into a record, you are multiplying their possibilities. A record with a Boolean, a Maybe String, and an Int can represent 2 × 2 × (all Ints) combinations. Most of those combinations will be meaningless in your domain, but your code must handle all of them — or hope nobody constructs the bad ones.

Sum types work in the other direction: they constrain possibilities. data Light = Red | Amber | Green has exactly three values, not the infinity of String. When you combine sums and products thoughtfully, you can shrink the representable state space until it closely matches the domain — and no closer.

The question to ask about any entity in your program — whether it is an ADT, a record, or a Map — is: what implicit domain knowledge have I failed to encode? Every fact about your domain that lives in a comment, a validation function, or a developer's head rather than in the type is a fact that the compiler cannot check. Some of those facts are hard to encode. Many are easy — and the only reason they are not encoded is that nobody stopped to think about the state space.

This is unfamiliar territory for programmers coming from languages without sum types. In those languages, throwing together a record (or object, or dictionary) and then validating and pruning and defending against it is the normal workflow. In PureScript, you have the tools to not represent those states in the first place. Use them.

8. Smart constructors: export the type, not the constructor

When a type has an invariant that the type system cannot express directly, enforce it with a smart constructor. Export the type and the constructor function, but not the data constructor. If you have worked in OO languages, this is the same idea as making a constructor private and exposing a factory method — PureScript enforces it through module exports rather than access modifiers.

module App.Types.Email
  ( Email         -- type only, no constructor
  , mkEmail       -- smart constructor
  , unEmail       -- accessor
  ) where

newtype Email = Email String

mkEmail :: String -> Maybe Email
mkEmail s
  | contains (Pattern "@") s && length s > 3 = Just (Email s)
  | otherwise = Nothing

unEmail :: Email -> String
unEmail (Email s) = s

Consumers of this module can create an Email only through mkEmail, which validates the invariant. They cannot write Email "not-an-email" because the Email constructor is not exported. Every Email value in the program is guaranteed to have passed validation.

This pattern composes well with Newtype deriving. Inside the module, you have full access to the constructor for implementing functions. Outside, the abstraction is sealed. The cost is one module boundary; the benefit is that the invariant is enforced once and relied upon everywhere.

This is "make illegal states unrepresentable" (entry 6) applied at the boundary: you cannot prevent the outside world from handing you "not-an-email", but you can ensure that if someone has an Email, it is a validated thing and not a ghastly string.

9. Design the types first, write the functions second

Try starting with the types. Before writing any logic, sketch the ADTs, the records, the newtypes. Write the type signatures for the functions you will need. Then fill in the implementations.

This is not "eat your vegetables" advice — it is one of the genuine pleasures of working in PureScript. A type signature is a conversation with the compiler about what your function needs, what it produces, and what can go wrong. If the signature is hard to write, that is useful information: it usually means the problem needs more thought. And once the types are in place, the compiler helps you write the code. It narrows the possibilities, flags wrong turns immediately, and often guides you to the implementation more efficiently than staring at a blank function body.

Often, once the types are right, the implementation is obvious — sometimes uniquely determined. A function Array (Tuple k v) -> Map k v has essentially one reasonable implementation. A function forall f a b. Functor f => (a -> b) -> f a -> f b has exactly one. The types do the thinking; the programmer merely transcribes.

Most experienced PureScript and Haskell programmers report that writing types first is the opposite of a chore — it is the moment where the design becomes clear. When the types are wrong, no amount of clever logic will save you. Fix the types and the functions simplify themselves.

10. Sum types for "or", product types for "and"

An algebraic data type is built from two operations: sum (a value is one of several alternatives) and product (a value has several fields together).

-- Sum: "A request is either pending, succeeded, or failed."
data Request a
  = Pending
  | Succeeded a
  | Failed String

-- Product: "A point has both an x-coordinate and a y-coordinate."
type Point = { x :: Number, y :: Number }

This sounds trivial, but programmers arriving from object-oriented languages systematically reach for inheritance hierarchies where a sum type is the correct model. In Java, you might write an abstract Shape class with Circle and Rectangle subclasses, then discover you cannot exhaustively match on them without instanceof checks. In PureScript, data Shape = Circle Number | Rectangle Number Number gives you exhaustiveness checking for free.

11. Records are PureScript's predominant product type

Haskell programmers may be surprised: in PureScript, records are the everyday product type. They are anonymous, structurally typed, and support row polymorphism — features that make them far more versatile than Haskell's named record fields.

-- A record type. No declaration needed — just use it.
type Point = { x :: Number, y :: Number }

-- Row polymorphism: works with any record that has a `name` field.
greet :: forall r. { name :: String | r } -> String
greet person = "Hello, " <> person.name

If you find yourself passing five related arguments to every function, that is a record waiting to be named. And if you find yourself writing a record where half the fields are Maybe because they only apply to certain variants — that is a sum type buried inside a product type, struggling to get out (see entry 6).

Records are fantastic as products but they have no notion of "or" — every field is always present. This means they can silently smuggle illegal states into your program. A record with a status :: String and a socket :: Maybe Socket and an error :: Maybe Error has multiplied together a vast space of combinations, most of which are nonsense. Always ask whether your record's fields can combine to represent something that should not exist (see entry 7).

12. Phantom types: unify through parameterisation

A phantom type parameter appears in a type's signature but not in its runtime representation. Its power is not merely to distinguish values — it is to unify disparate parts of a codebase through a shared, parameterised type.

newtype Id (entity :: Type) = Id String

derive newtype instance Eq (Id a)
derive newtype instance Ord (Id a)

lookupUser :: Id User -> UserMap -> Maybe User
lookupOrder :: Id Order -> OrderMap -> Maybe Order

-- This compiles:
lookupUser userId users

-- This does not:
lookupUser orderId users
-- Type error: Id Order does not unify with Id User

At runtime, both Id User and Id Order are plain strings. The User and Order parameters are erased during compilation; they cost nothing in memory or execution time. But the compiler treats them as distinct types, which means you cannot accidentally pass an order ID where a user ID is expected.

The deeper value of phantom types is unification. Every entity in your system — users, orders, invoices, sessions — shares a single Id type. Functions that work for any identified entity (fetchById :: forall e. Id e -> Aff (Maybe e)) are written once and apply everywhere. Lookup tables, caches, and logging infrastructure all parameterise over the same phantom, giving you a consistent vocabulary across modules that otherwise know nothing about each other. The phantom parameter is the thread that ties them together while the type checker keeps them apart where they need to be.

Without phantom types, you rely on variable naming conventions — userId, orderId — to keep identifiers straight, and you write separate id types or aliases for each entity. Conventions are suggestions; a shared parameterised type is architecture.

Phantom types have other uses beyond identifiers — tagged state machines, unit-safe quantities, and capability tokens among them. See the power tools section for more.

13. Use typed holes to ask the compiler for help

A typed hole — any identifier beginning with ? — tells the compiler: "I do not know what goes here; tell me what you expect."

render state =
  HH.div []
    [ HH.text ?help ]

The compiler responds with the expected type (String), the bindings in scope, and their types. This is not a workaround for incomplete code — it is a development technique. Use it when you know the shape of the expression but not the exact function name, when you are exploring an unfamiliar API, or when a type error is confusing and you want to see what the compiler actually expects at a specific position.

Typed holes are especially valuable in pipelines. Placing ?here in the middle of a composition chain tells you exactly what type flows through that point, without reading the signatures of every function in the chain.

14. Take the type the compiler gives you and search Pursuit

A typed hole tells you what the compiler expects. The next step: paste that type signature into Pursuit (PureScript's package search engine) and discover the function that already exists.

You place ?help in a pipeline and the compiler says it needs forall a. Maybe a -> a -> a. You paste that into Pursuit's type search and find fromMaybe. You are looking for something that does forall a. (a -> Boolean) -> Array a -> Array a — and Pursuit shows you filter. Often the function you are about to write is one Pursuit search away.

This technique is especially powerful for discovering traverse, foldMap, sequence, and other workhorse functions whose names are hard to guess but whose types are unambiguous. Let the type lead you to the function, not the other way around.

15. Always write type signatures on top-level declarations

The PureScript compiler infers types, but inference is a convenience for the author, not a service to the reader. A top-level binding without a type signature is a function whose contract must be reverse-engineered from its implementation.

Prefer:

buildIndex :: Array Entry -> Map EntryId Entry
buildIndex entries =
  Map.fromFoldable $ map (\e -> Tuple e.id e) entries

Over:

buildIndex entries =
  Map.fromFoldable $ map (\e -> Tuple e.id e) entries

The compiler warns on missing signatures for good reason. Without one, a small change to the implementation can silently change the inferred type, which in turn changes the type expected by every caller. A signature pins the contract. If the implementation no longer matches, the error appears where the change was made, not somewhere downstream.

Write the signature first. It is the function's spec.

16. Comment out your type signature and learn if it could be better

After writing a function, try this: comment out the type signature and rebuild. The compiler will warn about the missing signature and show you what it inferred. If the inferred type is more general than what you wrote — Foldable f => f a where you wrote Array a, or Semiring a => where you wrote Int — you may be over-constraining your function.

This is a conversation with the compiler, not a test. Sometimes the more general type is what you want — a function that works on any Foldable is more reusable than one locked to Array. Sometimes the specific type is better — it documents intent and gives better error messages. Either way, you are making the choice consciously, because the compiler told you what was possible.

17. Read compiler errors bottom-up

The PureScript compiler reports errors with context at the top and the specific mismatch at the bottom. A typical error reads:

  while checking that expression ...
    has type ...
  in value declaration myFunction
  where ...

  Could not match type
    String
  with type
    Int

New programmers read top-down, get lost in the framing ("while checking that expression..."), and give up before reaching the payload. The payload is at the bottom: "Could not match type String with type Int." Start there. The context above it tells you where the mismatch occurred, which you need only after you understand what the mismatch is.

This is the opposite of most programming language error conventions, where the first line is the important one. Adjust your reading order and the errors become significantly more useful.

18. Understand kind errors

PureScript distinguishes types by their kind — the "type of a type." Int has kind Type. Maybe has kind Type -> Type (it takes a type and returns a type). Effect has kind Type -> Type. A row of types has kind Row Type.

A kind error means you supplied a type constructor with the wrong number of arguments:

-- Correct: Maybe is applied to a type.
foo :: Maybe String -> String

-- Kind error: Maybe has kind Type -> Type, but Type was expected.
foo :: Maybe -> String

Read "expected kind Type, got kind Type -> Type" the same way you would read "expected type Int, got type String." The fix is the same in spirit: you gave the compiler the wrong thing. Usually you forgot to apply a type constructor to its argument, or applied it to too many.

Kind errors are more common when working with type classes (class Functor f requires f of kind Type -> Type) and when defining instances. If the error mentions Row Type, you likely wrote a record type where a row was expected, or vice versa.

19. Use type wildcards for irrelevant type variables

Some PureScript signatures carry type variables that the reader does not need to think about. Halogen component signatures are the canonical example, where State, Action, ChildSlots, Input, Output, and Monad all appear as type parameters, but a given function may only care about one or two of them.

Type wildcards let you focus the reader's attention:

-- Every variable named, most irrelevant to understanding this function.
raise :: forall state action slots input output monad.
  output -> HalogenM state action slots input output monad Unit

-- Wildcards for the irrelevant variables.
raise :: forall output. output -> HalogenM _ _ _ _ output _ Unit

The wildcard version says: this function takes an output value and raises it. The fully spelled-out version says the same thing, buried under six type variables that contribute nothing to the reader's understanding.

In library code and exported functions, explicit type variables serve as documentation — name them all. But in application code, those same type variables are often noise. Wildcards reduce that noise without losing information; in fact, they increase information by telling the reader "this type does not matter in this context." (Gary Burgess)

II. The rewards of totality

A total function handles every possible input. A partial function handles most of them and hopes for the best. PureScript's exhaustiveness checker, guard analysis, and non-empty types exist to help you write total functions — and to tell you when you have not. The entries in this section are variations on a single theme: leave no case unhandled.

20. Prefer case expressions over equational pattern matching

PureScript supports both equational-style pattern matching (multiple function clauses) and case expressions. The case form is more resilient to change.

Prefer:

describe :: Shape -> String
describe = case _ of
  Circle r    -> "Circle with radius " <> show r
  Square s    -> "Square with side " <> show s
  Rect w h    -> show w <> " by " <> show h

Over:

describe :: Shape -> String
describe (Circle r) = "Circle with radius " <> show r
describe (Square s) = "Square with side " <> show s
describe (Rect w h) = show w <> " by " <> show h

The equational form looks clean for simple cases. The trouble appears when you need to add a parameter. Adding a second argument to the equational version means rewriting every clause: describe lang (Circle r) = .... In the case version, you add the parameter once: describe lang = case _ of ....

The case _ of idiom also composes better with let bindings and where clauses — the function body is a single expression, and shared helpers are scoped to it naturally.

21. Use case _ of, not a named parameter you immediately case on

When a function's entire body is a pattern match on its argument, use case _ of — the anonymous lambda-case form.

Prefer:

colorFor :: Status -> String
colorFor = case _ of
  Active   -> "#2d5a27"
  Inactive -> "#999"
  Error    -> "#c23b22"

Over:

colorFor :: Status -> String
colorFor status = case status of
  Active   -> "#2d5a27"
  Inactive -> "#999"
  Error    -> "#c23b22"

The name status appears twice and communicates nothing the type signature did not already say. The case _ of form signals immediately that this function is defined by cases — its entire purpose is dispatching on the structure of its argument. The reader need not scan for other uses of status in the body, because there is no status to scan for.

This applies only when the argument is used once, for pattern matching, and nothing else. If the function also passes the argument to another function or uses it in a guard, name it.

22. Prefer guards over if-then-else

Guards align conditions vertically, making the decision structure scannable. Nested if-then-else indents rightward and buries the structure.

Prefer:

severity :: Int -> Severity
severity count
  | count > 100 = Critical
  | count > 10  = Warning
  | count > 0   = Info
  | otherwise   = None

Over:

severity :: Int -> Severity
severity count =
  if count > 100 then Critical
  else if count > 10 then Warning
  else if count > 0 then Info
  else None

Use if-then-else for simple binary choices where a guard would be heavier than the expression it protects — a ternary-style inline decision within a larger expression. For anything with more than two branches, guards are clearer.

23. End every guard chain with otherwise

The compiler will not let you get away with incomplete guards. If you write:

label :: Int -> String
label count
  | count > 0 = "positive"
  | count == 0 = "zero"

the compiler will insist on a catch-all:

A case expression could not be determined to cover all inputs.
The following additional cases are required to cover all inputs:

  _

Alternatively, add a Partial constraint to the type of the enclosing value.

This is the compiler doing its job. The fix is not to add a Partial constraint — that just pushes the problem to runtime. The fix is otherwise:

label :: Int -> String
label count
  | count > 0  = "positive"
  | count == 0 = "zero"
  | otherwise  = "negative"

otherwise is simply true, but its presence signals intent: "I have considered all cases." Guards are for predicates on values — comparisons, Boolean tests, numeric ranges. When your branches correspond to constructors of an ADT, use a case expression instead (see entry 24).

The same logic applies to case expressions on ADTs: do not use a wildcard pattern when you can match every constructor explicitly. A wildcard silently accepts new constructors added later, which is precisely the bug exhaustiveness checking exists to prevent (see entry 1).

24. Do not write guards when you should case on an ADT

Guards are for predicates — conditions on values. When you find yourself writing guards that test equality against constructors, you are doing the pattern matcher's job by hand, and losing exhaustiveness checking in the process.

Prefer:

describe :: Shape -> String
describe = case _ of
  Circle _ -> "round"
  Square _ -> "boxy"
  Rect _ _ -> "rectangular"

Over:

describe :: Shape -> String
describe s
  | isCircle s = "round"
  | isSquare s = "boxy"
  | otherwise  = "unknown"  -- what is this hiding?

Notice that every branch in the guards entry above (entry 21) uses predicates on Int — comparisons, not constructors. That is the right use of guards. When your branches correspond to constructors of an ADT, use a case expression and spell them all out.

25. Require NonEmpty when emptiness is impossible

If a function only makes sense with at least one element, say so in the type. Do not accept a possibly-empty collection and then scramble to handle the empty case with a default value or a partial function.

-- Prefer: the type guarantees at least one element.
chooseBest :: NonEmptyArray Candidate -> Candidate
chooseBest = maximumBy (comparing _.score)

-- Over: the Maybe infects every call site.
chooseBest :: Array Candidate -> Maybe Candidate
chooseBest = maximumBy (comparing _.score)

When you accept Array and return Maybe, you push the burden to the caller, who must handle Nothing even when they know the array is non-empty. When you accept NonEmptyArray, the caller must prove non-emptiness at the point of call — via fromArray, cons', or construction — and thereafter the proof is carried in the type.

The same logic applies to NonEmptyList, NonEmptyString, and NonEmpty f. Wherever "this cannot be empty" is an invariant, encode it. Invariants maintained by convention are invariants waiting to be broken.

26. Use guard and Alternative for conditional failure

When a computation should fail if a condition is not met, guard expresses this directly in any Alternative context. The pattern is the same whether the context is Maybe, List, Array, or a parser.

Start with the simplest case — Maybe:

lookupAdult :: Map Name Int -> Name -> Maybe Int
lookupAdult ages name = do
  age <- Map.lookup name ages
  guard (age >= 18)
  pure age

If the lookup fails, Nothing. If the guard fails, Nothing. No if/else, no explicit Nothing — each line is a precondition that must hold for the computation to continue.

The same idiom scales to richer contexts. In Array, guard filters:

eligiblePairs :: Array User -> Array (Tuple User User)
eligiblePairs users = do
  u1 <- users
  u2 <- users
  guard (u1.id /= u2.id)
  guard (u1.role == Admin || u2.role == Admin)
  pure (Tuple u1 u2)

And in a parser, guard rejects input that is syntactically valid but semantically wrong:

parsePort :: Parser String Int
parsePort = do
  n <- intDecimal
  guard (n > 0 && n <= 65535) <?> "port out of range"
  pure n

The underlying mechanism is Alternative — the type class that gives a computation a notion of failure (empty) and choice (<|>). guard is defined as guard true = pure unit; guard false = empty. Once you see it this way, the idiom transfers to any Alternative context you encounter.

27. Prefer Maybe over Boolean + separate value

When a value is meaningful only when some condition holds, represent it as Maybe — not as a Boolean flag with a separate field.

Prefer:

type Selection = Maybe SelectionInfo

Over:

type State =
  { hasSelection :: Boolean
  , selection :: Maybe SelectionInfo  -- NB: in a real codebase these fields may be far apart!
  }

The second version introduces an impossible state: hasSelection is true but selection is Nothing, or vice versa. Every function that touches this state must maintain the invariant that the two fields agree. Maybe encodes the invariant directly: Just means present, Nothing means absent. One field, no coordination, no impossible states.

This is a specific instance of the general principle from entry 6: if two fields must vary in lockstep, they are one field in disguise.

28. Use the strength of Maybe

Programmers arriving from JavaScript are accustomed to checking if (x !== null) and proceeding. The PureScript equivalent — pattern matching on Just and Nothing in every function — is correct but misses the point. Maybe has structure; use it.

Prefer:

displayName :: Maybe User -> String
displayName = maybe "Anonymous" \u -> u.firstName <> " " <> u.lastName

Over:

displayName :: Maybe User -> String
displayName user = case user of
  Just u  -> u.firstName <> " " <> u.lastName
  Nothing -> "Anonymous"

The goal is to keep values wrapped in Maybe as long as possible, operating on them through the interface, and unwrap only at the edge — when you render to the DOM, write to a log, or return a final result. fromMaybe provides a default at the boundary where you finally need a concrete value. Every early unwrap is a lost opportunity for the type system to track partiality on your behalf.

For the curious. Maybe is a functor, a monad, and an alternative — and knowing this unlocks more concise code. map transforms the value inside without touching Nothing. bind chains operations that might each fail. <|> expresses fallback: try this, and if it produces Nothing, try that. traverse runs an effectful function on the value if it exists and skips it if not. These are the same abstractions you will meet in Either, Array, and every other container in PureScript — learning them on Maybe pays compound interest.

29. Sometimes an ADT is a fixed map

Creating a Maybe and then immediately removing it is a smell. If you write fromMaybe right after Map.lookup, ask whether the lookup was necessary at all.

Here is the key insight: a function from an ADT is the same thing as a map with a fixed, known set of keys — except the compiler can verify that every key is handled. Pattern matching is the lookup. The compiler is the totality checker.

Prefer:

colorFor :: Status -> String
colorFor = case _ of
  Active   -> "#2d5a27"
  Inactive -> "#999"
  Error    -> "#c23b22"

Over:

colorFor :: Status -> String
colorFor s = fromMaybe "#000" $ Map.lookup s colors
  where
  colors = Map.fromFoldable
    [ Tuple Active "#2d5a27", Tuple Inactive "#999", Tuple Error "#c23b22" ]

Both versions map a Status to a String. But when you add a fourth constructor to Status, the pattern match will produce a compiler warning. The map will produce the wrong color, silently.

Reach for Map when the key set is open or dynamic (user IDs, file paths, configuration keys). Use a function with pattern matching when the key set is closed and known at compile time. The function version is simpler, faster, and checked.

30. Model domain errors as ADTs, not strings

When something goes wrong, the code that detects the failure knows what happened. A string error message flattens that knowledge into prose the caller cannot act on without parsing.

Prefer:

data AppError
  = InvalidInput Field String
  | Unauthorized
  | ResourceNotFound ResourceId
  | RateLimited Instant

handleError :: AppError -> Effect Unit
handleError = case _ of
  InvalidInput field reason -> highlightField field *> showToast reason
  Unauthorized              -> redirectToLogin
  ResourceNotFound id       -> show404 id
  RateLimited retryAt       -> showRetryTimer retryAt

Over:

handleError :: String -> Effect Unit
handleError msg
  | contains (Pattern "invalid") msg = showToast msg
  | contains (Pattern "unauthorized") msg = redirectToLogin
  | otherwise = showGenericError msg

The string version forces every consumer to reverse-engineer the producer's format. The ADT version lets the caller pattern-match on structure. Adding a new error case produces a compiler warning at every handler that has not been updated.

The thrower's job is to say what went wrong. The caller's job is to decide what to do about it. A string conflates both.

III. Effects are recipes

In PureScript, an effectful value does not do anything. It describes something to be done. This distinction — effects as data, not as actions — is the foundation of everything else: composition, concurrency, testing, and reasoning. Once the recipe metaphor clicks, the combinators are plumbing.

31. An Effect is a recipe, not an action

In JavaScript, calling fetch(url) sends the request. The function does the work. In PureScript, a value of type Effect Unit or Aff String does nothing at all. It is a description of work — a recipe that says "when executed, do this." The recipe only runs when it is wired into main or handed to the Halogen runtime or passed to launchAff_.

This distinction is not academic. It is the reason you can pass effects around as values, store them in data structures, compose them with >>= and <*>, and choose at the last moment whether to run them at all.

-- This does not log anything. It produces a value that, if run, would log.
greet :: Effect Unit
greet = log "hello"

-- This logs twice, because the recipe is executed twice.
main :: Effect Unit
main = do
  greet
  greet

The confusion usually surfaces when a newcomer writes a function that "should do something" but has no visible effect. The function is constructing a recipe; nothing runs it. If you find yourself asking "why doesn't this do anything?" — check whether the Effect or Aff value you built is actually composed into something that executes.

Every other rule about effects follows from this one. traverse_, when, launchAff_, forkAff — these are all combinators for assembling and running recipes. Once the recipe metaphor clicks, the rest is plumbing.

32. Use Aff for async, not callbacks [JavaScript]

This is what JavaScript async looks like when it accumulates:

fetchUsers(url, (err, users) => {
  if (err) return handleError(err);
  fetchConfig(configUrl, (err, config) => {
    if (err) return handleError(err);
    fetchPermissions(users[0].id, (err, perms) => {
      if (err) return handleError(err);
      render(users, config, perms);
    });
  });
});

Three levels of nesting, each with its own error check, and the actual work (render) buried at the deepest point. PureScript's Aff monad exists to absorb callbacks at the boundary and present sequential, typed code to the rest of your program.

-- Wrap a callback-based API once.
fetchText :: String -> Aff String
fetchText url = makeAff \callback -> do
  xhr <- newXHR
  onLoad xhr \_ -> do
    body <- responseText xhr
    callback (Right body)
  open xhr "GET" url
  send xhr
  pure nonCanceler

-- Then use it as plain sequential code.
loadAll :: Aff { users :: String, config :: String, perms :: String }
loadAll = do
  users <- fetchText "/api/users"
  config <- fetchText "/api/config"
  perms <- fetchText "/api/permissions"
  pure { users, config, perms }

The makeAff wrapper is the last place callbacks should appear. Once wrapped, everything downstream is do-notation — no nesting, no .then chains, no pyramid of doom. If you find callback-shaped indentation in PureScript, the boundary is in the wrong place.

33. Use when and unless, not if-then-pure-unit

When the else branch is pure unit, you are not making a choice — you are conditionally executing an effect. PureScript has a name for that.

Prefer:

when (Array.null items) do
  log "No items found"
  showEmptyState

Over:

if Array.null items
  then do
    log "No items found"
    showEmptyState
  else pure unit

when and unless (from Control.Monad) are not abbreviations; they are the precise statement of intent. The if/else pure unit version forces the reader to examine the else branch, confirm it does nothing, and then discard it. The when version says there is no else branch, and the reader moves on.

Note that when requires only an Applicative constraint, not Monad — so it works in more contexts than do notation does. This applies anywhere you find yourself writing else pure unit: Effect, Aff, StateT, Halogen's HalogenM, and any other Applicative.

34. Sometimes map will do

A do block that binds a value and immediately wraps a transformation of it in pure is a Functor operation wearing Monad clothing.

Prefer:

_.name <$> fetchUser id

Over:

do
  response <- fetchUser id
  pure response.name

The <$> version is shorter, communicates that no effects happen between the fetch and the transformation, and works with any Functor — not just Monad. The do version implies that something monadic is happening between the bind and the pure, and the reader must verify that nothing is.

The same principle extends to longer chains. If you find yourself writing do { x <- a; y <- pure (f x); z <- pure (g y); pure z }, you have g <<< f <$> a. Each unnecessary bind is a false signal of sequential dependence.

35. Understand Apply vs Bind and choose deliberately

PureScript gives you a choice that most languages do not. Bind (and do notation) sequences computations where each step may depend on the result of the previous one. Apply (and ado notation) combines computations that are independent.

Prefer:

ado
  user    <- fetchUser uid
  friends <- fetchFriends uid
  in { user, friends }

Over:

do
  user    <- fetchUser uid
  friends <- fetchFriends uid
  pure { user, friends }

The distinction is not merely semantic. In Aff, Apply can run both fetches concurrently via parApply. In Validation, Apply accumulates errors from both branches. In a free monad, Apply enables static analysis of the computation's structure. Bind forecloses all of these because it promises the second computation may depend on the first.

Use do when there is a genuine dependency — when the URL you fetch in step two is computed from the result of step one. Use ado when you are simply gathering independent results. The types are telling the truth either way; the question is whether you are.

36. ado notation: powerful but know the sharp edges

The previous entry introduced the Apply/Bind distinction. ado (applicative do) is the syntax for the Apply side — it desugars to Apply rather than Bind, which means the computations are independent. In Aff, this enables concurrency. In Maybe and Either, it documents independence. In Validation, it is the only option (there is no Monad instance).

renderCard :: Aff HTML
renderCard = ado
  user    <- fetchUser userId
  avatar  <- fetchAvatar userId
  badges  <- fetchBadges userId
  in renderProfile user avatar badges

The case for ado is real. But the syntax introduces subtleties that trip up newcomers and experienced programmers alike:

Nate Faubion on the PureScript Discord: "ado syntax is well defined and makes sense in the context of applicatives, but in comparison to do, its scoping for each bind and let is different, which can be confusing to newcomers to understand why something that looks so superficially similar actually operates so differently."

His recommendation for beginners: ignore ado initially. "Why does this exist when I can just use do" is the immediate question, and the answers require understanding Applicative vs Monad — a distinction most newcomers are still building intuition for.

For experienced programmers, ado is a precise tool. For codebases with mixed experience levels, do with a comment noting independence may be clearer. See also De Gustibus: "ado vs liftN."

37. Applicative for building, Monad for deciding

If every part of a computation can proceed independently, use Applicative (or ado notation). If the next step depends on the result of the previous one, you need Monad.

-- Applicative: all fields are computed independently.
mkUser :: Validation Errors User
mkUser = ado
  name  <- validateName rawName
  email <- validateEmail rawEmail
  age   <- validateAge rawAge
  in { name, email, age }

-- Monadic: the second query depends on the first result.
fetchProfile :: Aff Profile
fetchProfile = do
  user    <- fetchUser userId
  friends <- fetchFriends user.friendListId  -- needs user first
  pure { user, friends }

The distinction is not merely stylistic. Validation has an Applicative instance but deliberately lacks a Monad instance, because it needs to evaluate all fields to collect all errors. If it were monadic, a failure in validateName would short-circuit and you would never learn that validateEmail also failed. The type class hierarchy encodes a real semantic difference: applicative computations have a static structure; monadic computations have a dynamic one.

When you reach for do notation, ask whether each bind genuinely depends on a previous result. If the answer is no — if you are merely building up a record from independent parts — ado is both more honest and more powerful.

38. Attach a Canceler to every makeAff

Every makeAff must return a Canceler. This is not a suggestion from the type system — it is a requirement. But the compiler only checks that you return a Canceler, not that it does anything useful. The responsibility for meaningful cancellation is yours.

-- Correct: clean up the resource on cancellation.
listenOnce :: EventTarget -> String -> Aff Event
listenOnce target eventName = makeAff \callback -> do
  listener <- eventListener \ev -> callback (Right ev)
  addEventListener (EventType eventName) listener false target
  pure $ Canceler \_ -> liftEffect $
    removeEventListener (EventType eventName) listener false target

-- Acceptable when there is genuinely nothing to cancel.
sleep :: Int -> Aff Unit
sleep ms = makeAff \callback -> do
  _id <- setTimeout ms (callback (Right unit))
  pure nonCanceler

Forgetting the canceler — or always returning nonCanceler out of habit — means fibers cannot be cleanly killed. Event listeners accumulate, timers fire into dead contexts, and network requests complete for nobody. Even the sleep example above would benefit from clearTimeout in a production codebase. When in doubt, cancel something. When truly nothing can be cancelled, write nonCanceler explicitly so the reader knows you considered it.

39. Use parallel/sequential for concurrent Aff

When two asynchronous operations do not depend on each other, run them concurrently. PureScript provides two mechanisms: the parallel/sequential combinators (and their convenience wrappers parTraverse, parSequence) and manual fiber management (forkAff/joinFiber). Prefer the first.

-- Prefer: declarative concurrency via parTraverse.
loadAll :: Array String -> Aff (Array String)
loadAll urls = parTraverse fetchText urls

-- Or with applicative combinators:
loadDashboard :: Aff Dashboard
loadDashboard = sequential $
  { users: _, metrics: _, config: _ }
    <$> parallel (fetchText "/users")
    <*> parallel (fetchText "/metrics")
    <*> parallel (fetchText "/config")
-- Over: manual fiber management for simple concurrency.
loadDashboard :: Aff Dashboard
loadDashboard = do
  fiberU <- forkAff (fetchText "/users")
  fiberM <- forkAff (fetchText "/metrics")
  fiberC <- forkAff (fetchText "/config")
  users   <- joinFiber fiberU
  metrics <- joinFiber fiberM
  config  <- joinFiber fiberC
  pure { users, metrics, config }

The parallel combinators express the structure of the concurrency — these things are independent — without requiring you to manage the mechanism of fibers. Reserve forkAff for long-running background work where you genuinely need a handle to the fiber for later supervision or cancellation.

40. parTraverse and parSequence cover 80% of parallel Aff

PureScript's Aff monad provides parallel combinators through the Parallel type class. For most use cases, two functions are sufficient:

-- Fetch three resources in parallel.
results <- parTraverse fetchResource ["users", "posts", "comments"]

-- Or with independently typed actions:
{ users, posts } <- sequential $ { users: _, posts: _ }
  <$> parallel (fetchUsers orgId)
  <*> parallel (fetchPosts orgId)

Do not manually compose parallel and sequential unless you need something that parTraverse and parSequence cannot express. The manual version is verbose and error-prone — forgetting sequential or misplacing parallel produces confusing type errors.

For bounded concurrency — running at most N requests simultaneously — use an AVar as a semaphore with bracket to acquire and release permits. This is a straightforward pattern that composes with the existing parallel combinators rather than replacing them. (Nate Faubion)

41. Use STRef with ST.run for locally-scoped mutation

It might surprise you to learn that PureScript has an escape hatch for mutation — but it should not surprise you to learn that it is a very principled and precise one. When an algorithm needs mutable state for performance — building an array in a loop, accumulating into a hash map, running an in-place sort — ST gives you mutation with a pure interface.

import Control.Monad.ST as ST
import Control.Monad.ST.Ref as STRef

histogram :: Array Int -> Array Int
histogram values = ST.run do
  counts <- STRef.new (Array.replicate 256 0)
  for_ values \v -> do
    STRef.modify (\arr -> Array.modifyAt v (_ + 1) arr # fromMaybe arr) counts
  STRef.read counts

The rank-2 type of ST.run(forall h. ST h a) -> a — guarantees that the mutable reference cannot escape the block. The result is a pure value. Callers see histogram :: Array Int -> Array Int; they cannot tell that mutation was used internally, and they do not need to.

This is the right tool when you need imperative performance characteristics inside a pure function. It is not a license to write imperative PureScript everywhere — most code does not need mutable state. But when the alternative is threading an accumulator through a hundred recursive calls, ST is both faster and clearer.

There is a larger lesson here. It is not the case that a purely functional language cannot handle mutation — it handles it with explicit, scoped, type-checked tools like ST. The discipline is not avoidance; it is acknowledgement. When you use ST, you are forced to think about the scope and lifetime of your mutation, and the type system ensures the answer is sound. Contrast this with a language where every variable is mutable by default and the discipline is "just be careful."

42. Prefer Ref only at application boundaries

Effect.Ref is mutable state in Effect. It is the right tool for state shared between independent event handlers — a WebSocket connection pool, a cache that outlives a single request, a counter incremented by callbacks from different sources.

It is not the right tool for state within a single computation.

Prefer:

-- State threaded through a computation: use StateT or a fold
processItems :: Array Item -> State Summary Unit
processItems = traverse_ \item ->
  modify_ (addToSummary item)

Over:

-- Mutable ref where none is needed
processItems :: Array Item -> Effect Summary
processItems items = do
  ref <- Ref.new emptySummary
  for_ items \item ->
    Ref.modify_ (addToSummary item) ref
  Ref.read ref

The Ref version works, but it is Effect-bound for no reason. The computation has a single thread of control and produces a single result — exactly the scenario where State, foldl, or ST serves better. The Ref version forces every caller into Effect, prevents the logic from being tested purely, and hides the fact that the mutation is strictly local.

Reserve Ref for genuinely shared, long-lived mutable state at the edges of your application. For everything else, PureScript offers better tools.

43. Use newtypes for monad transformer stacks

A type alias for a transformer stack is transparent — every function that uses it must be compatible with the fully expanded type, and error messages show the expanded form.

Prefer:

newtype AppM a = AppM (ReaderT Config (ExceptT AppError Aff) a)

derive newtype instance Functor AppM
derive newtype instance Apply AppM
derive newtype instance Applicative AppM
derive newtype instance Bind AppM
derive newtype instance Monad AppM
derive newtype instance MonadAsk Config AppM
derive newtype instance MonadThrow AppError AppM
derive newtype instance MonadEffect AppM
derive newtype instance MonadAff AppM

Over:

type AppM = ReaderT Config (ExceptT AppError Aff)

The newtype version lets you write doSomething :: AppM Unit and see AppM in error messages, not ReaderT Config (ExceptT AppError Aff). The derived instances are one-time boilerplate — they delegate to the wrapped stack and cannot be wrong. The type alias version saves five minutes of setup and costs readability for the life of the project.

The newtype also gives you a place to hang custom instances. If AppM needs a MonadLogger instance that logs to a specific sink, you define it on the newtype. With a type alias, you would need an orphan instance or a workaround.

44. Prefer polymorphic monad constraints over concrete Effect

When a function performs effects, writing its type with a concrete Effect monad locks it into a specific execution context. Writing it with a constraint leaves room for the future.

-- Concrete: works, but inflexible.
getUser :: UserId -> Effect User

-- Polymorphic: works in any monad that can perform effects.
getUser :: forall m. MonadEffect m => UserId -> m User

The polymorphic version works identically when called in Effect. But when you later wrap your application in a ReaderT Config Aff stack, the concrete version requires liftEffect at every call site. The polymorphic version works unchanged.

Testing benefits are equally significant. You can run the polymorphic version in a test monad that logs calls without performing them. The concrete version can only be tested by running the real effect.

This does not mean every function should carry monad constraints. Pure functions should stay pure. But when a function genuinely needs effects, MonadEffect m or MonadAff m is almost always preferable to naming the concrete monad. (ntwilson)

45. A transformer stack is only as stack-safe as its base

If your monad transformer stack bottoms out in Identity, your bind chains are not stack-safe. Each >>= adds a frame, and deep recursion will overflow.

-- Stack-unsafe: Identity does not trampoline.
type PureComp = ReaderT Config (StateT AppState Identity)

-- Stack-safe: Trampoline is a free monad with constant stack usage.
type PureComp = ReaderT Config (StateT AppState Trampoline)

Aff is already stack-safe, so a ReaderT Config Aff stack inherits that property. The problem arises specifically with pure transformer stacks over Identity and with long bind chains — the kind you get from recursive computations or processing large data structures monadically.

A related subtlety: MonadRec and tailRecM provide explicit stack-safe recursion, but using them with an already stack-safe base monad like Aff doubles the number of binds for no benefit. Reach for tailRecM at the point of final interpretation, not as a blanket precaution. (Nate Faubion)

IV. Errors and failure

There are two kinds of failure: the kind your program expects and the kind it does not. PureScript gives you different tools for each. Confusing them — catching unexpected errors, ignoring expected ones, or mixing both into one channel — produces code that is hard to reason about and harder to maintain.

46. Either short-circuits; know when that is what you want

Either's Bind instance stops at the first Left. Each step sees the result of the previous one, and if any step fails, the rest never runs. This is exactly right for sequencing dependent operations.

processOrder :: OrderInput -> Either AppError Receipt
processOrder input = do
  user    <- lookupUser input.userId       -- fails here? stop.
  address <- validateAddress user.address  -- depends on user
  charge  <- billCard user.card address    -- depends on both
  pure (Receipt charge address)

Each step genuinely depends on the previous one — you cannot validate the address without the user, and you cannot bill without both. Either's short-circuiting is the correct behaviour here: there is nothing useful to do after a failure.

The mistake is reaching for Either when the checks are independent — see entry 47.

47. Use V (Validation) to accumulate independent errors

When checks do not depend on each other, Either's short-circuiting is a disservice. A form with five bad fields should report all five, not make the user fix them one at a time.

V from purescript-validation has an Apply instance that accumulates errors using a Semigroup:

import Data.Validation.Semigroup (V, invalid)

validateUser :: Input -> V (Array String) User
validateUser input = ado
  name  <- validateName input.name
  email <- validateEmail input.email
  age   <- validateAge input.age
  in { name, email, age }

With Either, this ado block would yield only the first failure. With V, all three checks run and all failures are collected. The ado notation makes the independence visible: each line binds from the input, not from a previous result.

V has no Monad instance — by design. You cannot write do notation with it, because do implies sequencing, and sequencing implies short-circuiting. The restriction is the feature.

48. Use ExceptT for expected failures, Aff's error for unexpected ones

Aff has a built-in error channel that carries JavaScript Error values. This is the right place for failures that indicate something has gone genuinely wrong — a network socket closed, a file could not be read, memory was exhausted. These are not part of your domain; they are part of the runtime's.

Domain errors — a user not found, a validation that failed, a permission denied — are expected. They are part of the application's normal control flow. Layer ExceptT over Aff to keep them in the types:

type AppM = ExceptT AppError Aff

fetchUser :: UserId -> AppM User
fetchUser uid = do
  response <- lift $ Fetch.get ("/users/" <> show uid)  -- network error stays in Aff
  case decodeUser response.body of
    Left err -> throwError (MalformedResponse err)       -- domain error in ExceptT
    Right user -> pure user

The separation pays off at the call site. An Aff error means something unexpected happened and you probably need to log it and show a generic message. An ExceptT error means something expected happened and you can handle it precisely. Mixing the two channels means every handler must inspect the error to decide which kind it is.

49. Do not catch exceptions you cannot handle

try converts an exception into an Either. This is useful when you have a meaningful response to the failure — a fallback value, an alternative code path, a user-facing message. It is not useful when you intend to re-throw, log and crash, or immediately fromRight.

-- This catches an exception only to make the failure harder to diagnose.
result <- try (loadConfig path)
config <- case result of
  Left err -> throwError (error $ "Config failed: " <> message err)
  Right c  -> pure c

The re-throw discards the original stack trace and replaces a specific exception with a vaguer one. The code would be clearer — and the error more useful — if the exception simply propagated.

Catch an exception when you can do something about it: retry, fall back, degrade gracefully. If you cannot, let it pass through. A caught-and-rethrown exception is not handled; it is laundered.

50. Avoid dual error channels: do not return Effect (Either e a)

When a function returns Effect (Either AppError a), it has two ways to fail: the Effect can throw a JavaScript exception, and the Either can be Left. The caller must handle both, and inevitably one channel is forgotten.

-- Two error channels: which one should the caller check?
loadConfig :: FilePath -> Effect (Either ConfigError Config)

-- Caller must handle both:
result <- try (loadConfig path)
case result of
  Left exn          -> -- JavaScript exception
  Right (Left err)  -> -- domain error
  Right (Right cfg) -> -- success

This is the wrong shape. If your errors are structured and recovery depends on the error type, use ExceptT:

loadConfig :: FilePath -> ExceptT ConfigError Effect Config

If your errors are simple and you only need to catch them at a boundary, use plain exceptions and try at the outer edge:

loadConfig :: FilePath -> Effect Config
-- throws on failure; caller uses `try` if recovery is needed

Either approach gives you one error channel. The Effect (Either e a) pattern gives you two and guarantees that someone, somewhere, will handle the wrong one. (ntwilson, Nate Faubion)

51. Use unsafeCrashWith for genuinely unreachable code

Sometimes the type system cannot prove that a branch is unreachable, but you know it is. Perhaps you have established the invariant elsewhere, or the surrounding logic excludes the case. Mark these branches explicitly with unsafeCrashWith.

import Partial.Unsafe (unsafeCrashWith)

lookupOrDie :: forall k v. Ord k => k -> Map k v -> v
lookupOrDie k m = case Map.lookup k m of
  Just v  -> v
  Nothing -> unsafeCrashWith "lookupOrDie: key missing from map assumed to be complete"

This is better than an incomplete pattern match, which the compiler may or may not warn about and which produces an unhelpful runtime error. It is better than unsafePartial $ fromJust, which hides the crash behind two layers of indirection. And the message string serves as documentation — it explains why the author believed the branch was unreachable, which helps the person debugging when that belief turns out to be wrong. (Gary Burgess)

Use this sparingly. Every unsafeCrashWith is a claim that the type system cannot verify. If you find yourself writing many of them, the types are not carrying enough information.

52. Alt and Alternative: first success wins

The <|> operator tries the left side; if it fails (produces Nothing, an empty array, a parse failure), it tries the right. This works for any type with an Alt instance, and it is the natural way to express fallback chains.

-- A cascade of lookups, most specific to least.
resolveConfig :: String -> Effect String
resolveConfig key =
  lookupEnv key
    <|> lookupFile configPath key
    <|> pure defaultValue

For Maybe, failure means Nothing. For parsers, failure means a failed parse. For arrays, <|> is concatenation — all successes, not just the first. The semantics vary by type, but the shape is always the same: try alternatives in order, combine the results according to the type's notion of success and failure.

<|> composes. Where an if-then-else chain or a nested case expression grows linearly and indents rightward, a chain of <|> stays flat. And because it is an operator on a type class, you can write functions that are polymorphic in the choice strategy — the same fallback logic works with Maybe, with parsers, with validation.

V. The FFI boundary

The foreign function interface is where PureScript's guarantees end and the host language's begin. Every entry in this section is about making that boundary as thin, honest, and verifiable as possible. The type checker cannot see across it; your discipline must bridge the gap.

53. Keep FFI files minimal; put logic in PureScript

A foreign module should do one thing: expose a host-language function to PureScript with an honest type. All branching, error handling, validation, and data transformation belong on the PureScript side, where the compiler can verify them.

// src/FFI/Clipboard.js — good: a single-purpose wrapper.
export const writeTextImpl = (text) => () =>
  navigator.clipboard.writeText(text);
-- src/FFI/Clipboard.purs — logic lives here.
foreign import writeTextImpl :: String -> Effect (Promise Unit)

writeText :: String -> Aff Unit
writeText s = do
  p <- liftEffect $ writeTextImpl s
  toAff p

The temptation is to handle edge cases in the host language — check for null, catch exceptions, massage data into shape — because "it's easier over there." Every line of logic in a foreign file is a line the type checker cannot see. Keeping the foreign code thin and the logic in PureScript lets the boundary be a boundary.

That said, this advice assumes a project that has committed to PureScript for its application logic. If you are introducing PureScript into an existing codebase incrementally — wrapping a few critical functions, testing the water — a thicker FFI layer may be a reasonable interim step. The principle still holds: move logic to PureScript as the PureScript surface area grows. The examples here use JavaScript, but the same applies to any backend's FFI (Erlang, Python, Lua).

54. Use EffectFn/Fn for uncurried FFI

PureScript functions are curried. JavaScript functions are not. When you call a JavaScript function that takes multiple arguments, or pass a PureScript callback to JavaScript, use Fn and EffectFn from Data.Function.Uncurried and Effect.Uncurried to match JavaScript's calling convention directly.

-- Prefer: uncurried types match the JavaScript signature.
foreign import addEventListenerImpl
  :: EffectFn3 String (EffectFn1 Event Unit) Element Unit

addEventListener :: String -> (Event -> Effect Unit) -> Element -> Effect Unit
addEventListener evt cb el =
  runEffectFn3 addEventListenerImpl evt (mkEffectFn1 cb) el
-- Over: curried foreign import requires a manual wrapper in JavaScript.
foreign import addEventListenerImpl
  :: String -> (Event -> Effect Unit) -> Element -> Effect Unit
-- The .js file must now manually handle currying:
-- export const addEventListenerImpl = (evt) => (cb) => (el) => () => ...

The uncurried variants avoid an intermediate currying wrapper in the JavaScript file and are faster at the call boundary. More importantly, they make the FFI file trivial — often just export const foo = someBuiltin; — which is exactly where you want the complexity to be: nowhere.

55. Use Nullable for values that may be null

JavaScript APIs routinely return null or undefined. Rather than pretending the value will always be there (and crashing at runtime), use Nullable from Data.Nullable to make the possibility explicit at the FFI boundary.

foreign import getElementByIdImpl :: String -> Effect (Nullable Element)

getElementById :: String -> Effect (Maybe Element)
getElementById id = toMaybe <$> getElementByIdImpl id

Nullable exists specifically for this: it maps directly to JavaScript's null/undefined semantics and converts cleanly to Maybe via toMaybe. It is the right tool for single values that might be absent. Do not use Foreign decoding when Nullable suffices — the lighter tool communicates the simpler situation.

For richer structures coming across the boundary, see entry 56.

56. Parse, don't validate — especially at the boundary

Any data arriving from outside your program — JSON from an API, query parameters from a URL, configuration from a file, a return value from a JavaScript function — should be parsed into a typed representation at the boundary and never trusted as raw input beyond that point. This is what Alexis King calls "parse, don't validate": do not check that the data looks right and then use it unsafely — transform it into a type that cannot be wrong.

The FFI is the most common boundary. JavaScript can return a number where you expected a string, or an object missing half its fields. The PureScript type system has no jurisdiction over foreign land. It is your job to check papers at the border.

-- Safe: treating the return as foreign data and decoding it.
foreign import getConfigImpl :: Effect Foreign

getConfig :: Effect (Either String { timeout :: Int, retries :: Int })
getConfig = do
  raw <- getConfigImpl
  pure $ runExcept $ do
    timeout <- readInt =<< readProp "timeout" raw
    retries <- readInt =<< readProp "retries" raw
    pure { timeout, retries }

-- Dangerous: trusting JavaScript to return the right shape.
foreign import getConfigImpl :: Effect { timeout :: Int, retries :: Int }

The Foreign decoder is a parser. Once it succeeds, the result is a genuine PureScript value with full type guarantees. If the JavaScript function "always returns a string," it will return undefined the week after you ship.

57. Never use unsafeCoerce as a substitute for proper types

Unsafe.Coerce.unsafeCoerce tells the compiler "trust me, this value has this type." The compiler obliges. It has no choice. When you are wrong — and you will eventually be wrong — the error surfaces at runtime, far from the coercion, with no indication of what went awry.

-- This also compiles, and fails at compile time when the shape is wrong.
metadata :: Effect (Either String { title :: String })
metadata = do
  raw <- getMetadata
  pure $ decode raw
-- This compiles. It will fail at runtime in creative ways.
foreign import getMetadata :: Effect Foreign

metadata :: Effect { title :: String }
metadata = unsafeCoerce <$> getMetadata

Legitimate uses of unsafeCoerce exist — primarily in library internals where the author has proven a type equivalence that PureScript's type system cannot express. Application code should never need it. If you reach for unsafeCoerce because a Foreign decoder feels like too much ceremony, the ceremony is the point. It is the type system asking you to prove that you know what you have.

In a production codebase, consider adding a pre-commit check or CI step that flags any use of unsafeCoerce. The function has legitimate uses in library internals, but its presence in application code is almost always a sign that something should be decoded or typed properly.

58. Suffix foreign imports with Impl; hide them behind a wrapper

The boundary between JavaScript and PureScript is the most dangerous line in your codebase. Mark it clearly.

-- Foreign import: uncurried, suffixed with Impl.
foreign import joinPathImpl :: Fn2 String String String

-- PureScript wrapper: curried, exported.
joinPath :: String -> String -> String
joinPath start end = runFn2 joinPathImpl start end
// Foreign module: pure JavaScript, no PureScript knowledge required.
export function joinPathImpl(start, end) {
  return start + "/" + end;
}

The Impl suffix signals that this function is an implementation detail — not for direct consumption. The wrapper function is where PureScript types begin and JavaScript types end. Validation, Maybe wrapping, and Effect thunking all belong in the wrapper, not in the foreign module.

Export joinPath. Do not export joinPathImpl. The module boundary is your firewall. (Official FFI Tips Guide)

See also entries 153 and 154 for related FFI discipline.

59. Do not go point-free with runFn

The PureScript compiler inlines runFn2, runFn3, and their siblings only when they are fully saturated — applied to all their arguments. A point-free definition defeats this optimisation.

-- Inlined: the compiler sees all arguments and generates a direct call.
joinPath :: String -> String -> String
joinPath start end = runFn2 joinPathImpl start end

-- NOT inlined: the compiler sees a partial application and generates a closure.
joinPath :: String -> String -> String
joinPath = runFn2 joinPathImpl

The two definitions are semantically identical, but the point-free version produces a closure that wraps the foreign function call. In a hot path — an inner loop, a rendering function called thousands of times — this overhead is measurable.

This is one of the few places where the general De Gustibus tolerance for point-free style does not apply. The runFn family has specific compiler support that depends on syntactic saturation. Name the arguments. (Official FFI Tips Guide)

60. Do not mutate input records in FFI code

JavaScript FFI functions receive PureScript values directly. If a foreign function modifies its arguments in place, it violates the fundamental contract of a pure language: that values do not change after construction.

// WRONG: mutates the input.
export function addTimestamp(record) {
  record.timestamp = Date.now();
  return record;
}
// RIGHT: returns a new object.
export function addTimestamp(record) {
  return { ...record, timestamp: Date.now() };
}

The first version looks correct from the JavaScript side but causes silent corruption in PureScript. Any other reference to the original record now sees the mutated version. If the record was shared — passed to multiple functions, stored in state — the mutation propagates unpredictably.

This applies to arrays, typed arrays, and any mutable JavaScript object. If your FFI function needs to modify data, copy first. Or better, structure your FFI so that the PureScript wrapper constructs the new value and the foreign function only performs the operation that requires JavaScript. (wclr)

61. Never call PureScript code from foreign modules

Do not import PureScript-generated modules in your JavaScript FFI files. Do not reference constructors like Data_Maybe.Just.create(x) or call functions from the output/ directory.

// WRONG: reaches into PureScript's generated code.
import * as Maybe from "../output/Data.Maybe/index.js";

export function safeDivide(a, b) {
  if (b === 0) return Maybe.Nothing.value;
  return Maybe.Just.create(a / b);
}
-- RIGHT: pass constructors from PureScript.
foreign import safeDivideImpl :: Fn3 (forall a. a -> Maybe a) (forall a. Maybe a) Number Number (Maybe Number)

safeDivide :: Number -> Number -> Maybe Number
safeDivide = runFn3 safeDivideImpl Just Nothing
export function safeDivideImpl(just, nothing, a, b) {
  if (b === 0) return nothing;
  return just(a / b);
}

The generated code is an implementation detail of the compiler. Its structure can change between compiler versions, its module paths can change with build tool updates, and referencing it directly defeats dead-code elimination — the bundler cannot tree-shake a constructor that JavaScript imports directly.

Pass what the foreign function needs from the PureScript side. The wrapper function is the place to supply constructors, type class methods, and callbacks. (Official FFI Tips Guide)

You can also pass PureScript functions as callbacks to JavaScript. An EffectFn1 a b on the PureScript side is a plain function(a) { return b } on the JavaScript side — no thunking, no currying. This is the right way to wire up event handlers, lifecycle hooks, and any JavaScript API that expects a callback.

62. Pass type class methods, not dictionaries, to FFI

When a foreign function needs to use a type class method — show, compare, encode — pass the resolved method as a function argument. Do not attempt to work with dictionary objects in JavaScript.

foreign import logWithLabelImpl :: Fn2 (forall a. a -> String) String (Effect Unit)

logWithLabel :: forall a. Show a => a -> String -> Effect Unit
logWithLabel value label = runFn2 logWithLabelImpl show label
export function logWithLabelImpl(showFn, label) {
  return function() {
    console.log(label + ": " + showFn(label));
  };
}

The PureScript compiler resolves type class instances to dictionary objects with a specific internal structure. That structure is not part of any public API — it can and does change between compiler versions. By passing the resolved method from the PureScript wrapper, the foreign module receives a plain function and needs no knowledge of the type class machinery. (Official FFI Tips Guide)

63. Remember that Effect values are thunks

In PureScript, an Effect value is a function of zero arguments — a thunk. The foreign module must wrap side effects in a function to defer their execution until PureScript's runtime invokes them.

// CORRECT: returns a thunk.
export function getCurrentTime() {
  return Date.now();
}

// WRONG: executes at import time.
export const getCurrentTime = Date.now();

The second version calls Date.now() when the module is loaded, not when the PureScript program calls getCurrentTime. The value is captured once and never updated. This is not a subtle difference — it is the difference between a program that reads the current time and a program that reads the time the module was loaded.

For effectful functions with arguments, each argument adds a layer of currying, and the final layer returns the thunk:

// writeFile :: String -> String -> Effect Unit
export function writeFile(path) {
  return function(content) {
    return function() {
      fs.writeFileSync(path, content);
    };
  };
}

The outermost functions receive the curried arguments. The innermost function() is the Effect thunk. Forgetting that final layer means the effect runs during argument application, not when the Effect is executed. (Official FFI Tips Guide)

64. Do not use unsafePerformEffect in production code

unsafePerformEffect executes an Effect and returns a "pure" value. It is the most dangerous function in the PureScript ecosystem, and its dangers are not obvious.

The compiler assumes pure values are referentially transparent — it may inline them, share them, reorder them, or evaluate them at unexpected times. An unsafePerformEffect value that mutates a Ref can be called zero times, once, or many times depending on compiler optimisations. Unused where bindings might still trigger side effects if the compiler does not eliminate them (or might not trigger them if it does).

-- This is a time bomb.
counter :: Ref Int
counter = unsafePerformEffect (Ref.new 0)

-- When does this execute? Before main? During module initialisation?
-- Is it shared across all call sites? The answer depends on the compiler version.

The only defensible use is as a transitional step during FFI prototyping — and even then, it should never be exported from a module. If you need a global mutable reference, initialise it in main and pass it through ReaderT. If you need a module-level constant, make it a pure value. (Nate Faubion, hdgarrood, Thomas Honeyman)

65. Declare type roles explicitly for foreign data and mutable newtypes

PureScript's coerce function can convert between types that differ only in newtype wrappers — but only when the type roles permit it. The compiler infers roles, but inference can be too permissive for types that wrap mutable state or foreign data.

-- Without explicit roles, the compiler infers `representational` for the parameter.
newtype MutableRef a = MutableRef (Effect.Ref a)

-- This allows:  coerce :: MutableRef Int -> MutableRef String
-- Which is unsound: the underlying Ref still holds an Int.

Declare roles explicitly to prevent unsafe coercions:

type role MutableRef nominal

newtype MutableRef a = MutableRef (Effect.Ref a)
-- Now: coerce :: MutableRef Int -> MutableRef String  -- type error

A nominal role means the type parameter is significant — MutableRef Int and MutableRef String are distinct types that cannot be coerced between. A representational role permits coercion when the inner types are themselves coercible (as with pure newtypes). A phantom role ignores the parameter entirely.

For foreign data declarations, the compiler cannot inspect the JavaScript implementation, so it defaults to conservative roles. Explicit annotations document your intent and protect against future changes. (purescript/purescript#4116)

VI. Type classes

Type classes in PureScript are not interfaces, not abstract classes, not traits. They are a mechanism for principled ad hoc polymorphism — functions that behave differently for different types, but within a framework of laws and guarantees. Understanding what they are (and are not) is essential to using them well.

66. ADTs for variants, type classes for ad hoc polymorphism

Type classes are PureScript's mechanism for ad hoc polymorphism — giving the same operation different behaviour for different types, within a framework of laws. Eq means "this type supports equality, and it is reflexive, symmetric, and transitive." Monoid means "this type has an associative binary operation with an identity element." The laws are the point; convenience is a side effect.

This makes type classes fundamentally different from both OOP interfaces and ADT-based dispatch. An ADT models a closed set of alternatives your program handles exhaustively. A type class models an open set of types that share a lawful interface — any module can add a new instance without modifying the class.

Use ADTs when you know all the cases. Use type classes when the set of types is open and the shared behaviour follows laws.

-- Closed set of known alternatives: ADT + pattern match.
data Notification = Email EmailAddress Body | SMS PhoneNumber Body | Push DeviceToken Body

deliver :: Notification -> Aff Unit
deliver = case _ of
  Email addr body -> sendEmail addr body
  SMS phone body  -> sendSMS phone body
  Push token body -> sendPush token body
-- A type class for a closed set gains nothing and loses exhaustiveness checking.
class Deliverable a where
  deliver :: a -> Aff Unit

A useful heuristic: if you are writing a class with one method and three instances, all defined in the same module, you almost certainly want a sum type with three constructors. (Nate Faubion)

67. Use newtypes to avoid orphan instance errors

PureScript enforces a strict rule: a type class instance must be defined in the module that defines the class or in the module that defines the type. Defining it anywhere else is an orphan instance, and the compiler rejects it outright.

-- In module MyApp.Display:
-- Does not compile: neither Show nor ThirdPartyType is defined here.
instance Show ThirdPartyType where
  show _ = "ThirdPartyType"

The solution is a newtype wrapper. The newtype is defined in your module, so you are free to give it any instance you like.

newtype Displayable = Displayable ThirdPartyType

instance Show Displayable where
  show (Displayable t) = "ThirdPartyType(...)"

This comes up constantly when integrating third-party libraries. You want Encode for a type from one package and Decode for a type from another, and the compiler refuses both. The newtype is not boilerplate — it is the module system telling you to be explicit about which behaviour you intend. If you control the type, put the instance with the type. If you control the class, put the instance with the class. If you control neither, newtype.

The restriction exists to prevent incoherence — the situation where two modules define different instances for the same class-type pair, and the one you get depends on which module was imported. Haskell allows orphans with a warning; PureScript closes the door entirely.

68. There are no default method implementations

In Haskell, a type class can provide default implementations for some methods in terms of others. You might define only fmap and get <$ for free. PureScript does not have this feature. Every method in every instance must be written.

-- You must provide both, even if one is trivially derived from the other.
instance Eq MyType where
  eq a b = ...

instance Ord MyType where
  compare a b = ...

This is a deliberate design choice — it keeps the instance resolution machinery simpler and the instance declarations explicit. The practical consequence is that shared logic should live in named helper functions, not in default methods. If five instances share the same implementation of a method, extract that implementation and call it from each instance. The repetition is real but small, and the explicitness prevents surprises when the "default" is not what you expected.

69. Give instances for containers you define

If you define a data structure that holds values, give it Functor, Foldable, and Traversable instances. Without them, every consumer must destructure your type manually, and it cannot participate in generic algorithms.

data Pair a = Pair a a

derive instance Functor Pair

instance Foldable Pair where
  foldl f z (Pair a b) = f (f z a) b
  foldr f z (Pair a b) = f a (f b z)
  foldMap f (Pair a b) = f a <> f b

instance Traversable Pair where
  traverse f (Pair a b) = Pair <$> f a <*> f b
  sequence (Pair fa fb) = Pair <$> fa <*> fb

With these three instances, Pair immediately works with traverse, for_, foldMap, sum, length, toArray, and every other function polymorphic over Foldable or Traversable. Without them, it is an island — usable only through its own API. The instances are small, often derivable, and they pay for themselves the first time someone writes traverse_ validate (Pair left right) instead of unpacking the pair by hand. In the age of AI-assisted coding, the cost of writing these instances — and the law-checking property tests to go with them — is negligible. There is no excuse for leaving them out.

More broadly: you will get terrific return on your investment from using existing container types — Set, Map, Graph, Tree, NonEmptyArray — rather than rolling your own. Not only do they come with all the instances, but using standard containers structures your thinking. A Set communicates "no duplicates, order irrelevant." A Map communicates "lookup by key." These are design decisions encoded in the type, and they come with tested, optimised implementations for free.

70. Write functions over Foldable, not concrete containers

When a function folds, traverses, or checks membership, constrain it with Foldable or Traversable rather than naming a specific container. The function works the same; the caller is free to supply whichever collection they have.

-- Prefer: works with Array, List, NonEmptyArray, Set, or any Foldable.
total :: forall f. Foldable f => f Int -> Int
total = foldl (+) 0

-- Over: needlessly locked to Array.
total :: Array Int -> Int
total = foldl (+) 0

The generalised version costs nothing at the call site — an Array Int is still a valid argument — and gains flexibility at every future call site that happens to have a List or NonEmptyArray.

A note on performance: on the JavaScript backend, Array is a JavaScript array with O(1) indexed access and good cache locality, making it the pragmatic choice when you need speed. But PureScript targets multiple backends — Erlang, Python, Lua — where the performance story is different. Writing to Foldable keeps your code portable. Specialise to a concrete type in the inner loop where profiling tells you to, not as a default across your API.

The Haskell habit of defaulting to [] (a cons list) and optimising later does not transfer directly. But the fix is not "always use Array" — it is "always abstract over the container, and choose the concrete type where it matters."

71. Use Traversable to combine effects over structures

Traversable generalises "do something effectful to each element and collect the results." If you find yourself pattern-matching on a container just to map an effectful function and reassemble, you are re-implementing traverse.

-- Prefer: traverse handles the structure.
validateAll :: forall f. Traversable f => f Input -> Either Error (f Validated)
validateAll = traverse validate

-- Over: manually destructuring and reassembling.
validateBoth :: Tuple Input Input -> Either Error (Tuple Validated Validated)
validateBoth (Tuple a b) = Tuple <$> validate a <*> validate b

traverse works with any Traversable container and any Applicative effect. This means the same function validates an Array, a List, a Maybe, or a Pair — and the effect can be Either, Aff, V, or anything else with an Applicative instance.

The key insight: Traversable is to effectful operations what Functor is to pure ones. Just as you would never manually unpack a container to apply a pure function (you use map), you should not manually unpack a container to apply an effectful one.

72. Minimise type class constraints

Do not constrain a function with Eq a => if the implementation never compares values of type a. Unnecessary constraints exclude valid call sites and misrepresent the function's actual requirements.

Prefer:

reverseList :: forall a. List a -> List a
reverseList = foldl (flip Cons) Nil

Over:

reverseList :: forall a. Eq a => List a -> List a
reverseList = foldl (flip Cons) Nil

Each constraint is a promise that the function uses that capability. An Eq constraint says "I compare values for equality somewhere in this implementation." A Show constraint says "I convert values to strings." If the promise is false, the function is lying about its requirements — and that lie has practical consequences. Function types, for instance, rarely have Eq instances; an unnecessary Eq a => prevents the function from being used with a ~ (Int -> Int).

The compiler does not warn about over-constrained functions. Discipline here is manual but worthwhile.

73. Do not use Show for serialisation

Show is for debugging. Its output format is not stable across compiler versions, not specified by any standard, and not guaranteed to be parseable. A Show instance is a convenience for the REPL and for log messages during development. It is not a serialisation format.

Prefer:

saveConfig :: Config -> Effect Unit
saveConfig config = writeFile "config.json" (stringify $ encodeConfig config)

Over:

saveConfig :: Config -> Effect Unit
saveConfig config = writeFile "config.txt" (show config)

If you need to serialise a value, write a codec — purescript-codec-argonaut, purescript-yoga-json, or a hand-rolled encoder. If you need a human-readable label for a UI, write a display function with an explicit, documented format. Show instances should appear in debug logs and test failure messages; they should never appear in data that crosses a process boundary, a network, or a file system.

The temptation is strongest with simple types — show myEnum produces something that looks reasonable today. But "today" is the operative word. When you add a constructor, rename one, or change the Show instance for readability, every consumer of that serialised string breaks silently.

74. Semigroup instances should compose, not silently discard

The Semigroup instance for Map was the subject of considerable community debate. The question: when two maps share a key, should append keep the left value, the right value, or merge the values using the inner type's Semigroup?

The community preference, and the current implementation, is unbiased: values at duplicate keys are merged via append on the value type. This is the principled choice — a Semigroup should compose, not silently discard information.

import Data.Map as Map

-- Inner Semigroup merges values.
Map.singleton "a" [1, 2] <> Map.singleton "a" [3, 4]
-- Result: Map.singleton "a" [1, 2, 3, 4]

When you want biased behaviour — keeping the first or last value — make the choice visible in the type:

import Data.Semigroup.First (First(..))
import Data.Semigroup.Last (Last(..))

-- Left-biased: wrap values in First.
map1 :: Map String (First Int)
map1 = Map.singleton "a" (First 1) <> Map.singleton "a" (First 2)
-- Result: Map.singleton "a" (First 1)

The First and Last newtypes document the bias at the type level. A reader encountering Map String (First Config) knows immediately that duplicate keys keep the first value. A bare Map String Config with a biased instance would require reading the instance definition — or discovering the behaviour through a bug. (kl0tl, monoidmusician, hdgarrood)

75. Avoid stringly-typed Symbol proxies when an ADT exists

Type-level strings (Proxy @"foo", SProxy "bar") are the foundation of PureScript's row polymorphism and generic programming. They are the right tool when you are writing generic code that operates over arbitrary record fields or variant labels.

They are the wrong tool when you are using them as runtime-level tags or enum-like values:

-- The Symbol buys you nothing here. It is a String with more steps.
handleEvent :: forall s. IsSymbol s => Proxy s -> Event -> Effect Unit

-- An ADT gives you exhaustiveness checking.
data EventKind = Click | Hover | Focus

handleEvent :: EventKind -> Event -> Effect Unit

The ADT version is checked for exhaustiveness. The Symbol version is checked for... existence. If you typo "clck", the compiler will happily create a new symbol and proceed. The error surfaces at runtime, or not at all.

Use Symbol for generic programming. Use ADTs for domain modeling.

VII. Containers and traversal

PureScript's standard library provides a small, well-designed set of containers and a rich algebra for working with them. The entries here cover the most common operations and the most common mistakes — places where a more direct combinator exists for what you are doing the long way.

76. Traverse; do not map and sequence

When you need to apply an effectful function to every element of a structure, use traverse. Do not map the function over the structure and then sequence the result.

Prefer:

loadAll :: Array FilePath -> Aff (Array String)
loadAll = traverse readTextFile

Over:

loadAll :: Array FilePath -> Aff (Array String)
loadAll paths = sequence (map readTextFile paths)

These are equivalent — traverse f is defined as sequence <<< map f — but the composed version says two things ("apply this to each element", then "collect the effects") where one will do. Worse, the two-step version invites the reader to wonder whether something happens between the map and the sequence, and the answer is always no.

Use for when the function reads more naturally after the structure:

for items \item ->
  H.liftEffect $ log item.name

77. Discard results deliberately

When you traverse for effect alone, use traverse_ or for_.

traverse_ removeFile tempFiles

Not:

void $ traverse (\f -> removeFile f) tempFiles

The underscore variants are not merely cosmetic. traverse must retain every result to build the output structure; traverse_ is free to discard each result as it goes. void $ traverse builds an Array Unit and then throws it away.

The same applies throughout the standard libraries. Prefer when over void $ if, for_ over void $ for. Where the library offers a variant that matches your intent, use it. The reader should not have to subtract the parts you did not mean.

78. Use foldMap instead of map followed by fold

When you need to transform each element and then combine the results under a Monoid, foldMap does both in a single pass.

-- Prefer: one pass, clear intent.
renderNames :: forall f. Foldable f => f User -> String
renderNames = foldMap (\u -> u.name <> "\n")

-- Over: two passes, intermediate structure.
renderNames :: forall f. Foldable f => f User -> String
renderNames = fold <<< map (\u -> u.name <> "\n")

foldMap f is defined as fold <<< map f, so the two are semantically equivalent. But the single-pass version avoids constructing an intermediate collection, and more importantly, it says "transform and combine" as one thought rather than two. When you see foldMap, you know the shape immediately: a function into a monoid, applied across a structure. When you see fold <<< map, you must read both to confirm they are not doing something more complex.

79. Use Data.Map and Data.Set, not hand-rolled lookups

If you find yourself writing findFirst (\x -> x.id == target) items or manually deduplicating with nub, step back. The ordered-collections package provides Map and Set with proper logarithmic-time operations.

-- Prefer: a Map built once, queried many times.
userMap :: Map UserId User
userMap = Map.fromFoldable $ map (\u -> Tuple u.id u) users

lookupUser :: UserId -> Maybe User
lookupUser = flip Map.lookup userMap

-- Over: linear scan on every lookup.
lookupUser :: UserId -> Array User -> Maybe User
lookupUser uid = find (\u -> u.id == uid)

The difference is not only performance. Map and Set make the intent legible: this is a collection keyed by something, or a collection of unique things. An Array that you happen to search linearly communicates nothing about its access pattern.

Build the Map at the boundary where the data arrives. Pass it inward. Do not convert back to Array for a function that will only look things up.

The same principle applies to other container types. If your data is a tree, use Data.Tree (from rose-trees). If it has graph structure — nodes with edges, cycles, dependencies — use a graph library rather than an Array of records with ID references. The container type documents the structure; the library provides the algorithms.

80. Use coerce for zero-cost newtype conversions

You may be surprised to learn that PureScript has a coerce function (not unsafeCoerce — the safe kind). How can that be? Because newtypes are erased at runtime, and coerce simply tells the compiler "these two types have the same runtime representation — trust me, and check." The compiler does check, and it is O(1).

newtype Score = Score Int
derive instance Newtype Score _

rankings :: Array Score
rankings = [Score 42, Score 98, Score 71]

-- Prefer: O(1), the array is not traversed.
scores :: Array Int
scores = coerce (rankings :: Array Score)
-- scores == [42, 98, 71]

-- Over: O(n), mapping a function that does nothing at runtime.
scores :: Array Int
scores = map unwrap rankings

coerce works not only on arrays but on any type whose structure the compiler can verify as representationally identical: Map k (Additive Int) to Map k Int, Maybe Score to Maybe Int, nested combinations thereof.

The constraint is that the newtype constructor must be in scope — if a module exports the type but not its constructor, coerce correctly refuses to bypass the abstraction. This is the right behaviour: a newtype with a hidden constructor is enforcing an invariant, and stripping it silently would defeat the purpose.

81. But what if you don't want two newtypes to be coercible?

The previous entry explains how coerce works. But sometimes you want to prevent coercion — for example, when a newtype wraps mutable state and converting between them would be unsound.

This is what type roles are for. See entry 65 in the FFI section for the full treatment. The short version: if you declare type role MutableRef nominal, then coerce :: MutableRef Int -> MutableRef String becomes a type error. The nominal role says "these type parameters are significant, not just representational wrappers."

If you write a newtype that enforces an invariant via a smart constructor (entry 8), you probably want its role to be nominal too — otherwise coerce can bypass your smart constructor from any module that has the newtype constructor in scope.

82. Use Data.Newtype.un, over, and over2

The Newtype class provides generic functions for working with newtypes without importing or mentioning the constructor. This keeps code resilient to refactoring and avoids unnecessary coupling to a type's internal structure.

Prefer:

adjustScore :: Score -> Score
adjustScore = over Score (_ * 2 + 1)

combineScores :: Score -> Score -> Score
combineScores = over2 Score (+)

readScore :: Score -> Int
readScore = un Score

Over:

adjustScore :: Score -> Score
adjustScore (Score n) = Score (n * 2 + 1)

combineScores :: Score -> Score -> Score
combineScores (Score a) (Score b) = Score (a + b)

readScore :: Score -> Int
readScore (Score n) = n

The manual version is not wrong, but it repeats the constructor name at every use site. If Score is renamed or restructured, every pattern match must be updated. The Newtype functions work with any newtype — they are parameterised by the class, not the constructor name.

For collections, this matters more: map (over Score (_ + 1)) scores reads as a single transformation, while map (\(Score n) -> Score (n + 1)) scores buries the intent in wrapping and unwrapping.

83. Use intercalate, not manual separator logic

Building a delimited string by folding with a conditional separator is a recurring source of off-by-one errors: an extra comma at the end, a missing comma at the start, special-casing the first or last element.

Prefer:

intercalate "; " ["alpha", "beta", "gamma"]
-- "alpha; beta; gamma"

Over:

foldlWithIndex
  (\i acc s -> if i == 0 then s else acc <> "; " <> s)
  ""
  ["alpha", "beta", "gamma"]

intercalate from Data.Foldable (for strings) or Data.Array (for arrays) handles the separator logic correctly and communicates intent in a single word. The fold version is four lines of control flow to achieve what a standard library function already does. This generalises: before writing separator logic, check whether intercalate or joinWith already exists for your type.

84. Use Tuple only for ephemeral pairs

Tuple String Int tells the reader nothing about which string or which int. It is a pair without identity — suitable for the intermediate steps of a pipeline, but not for data that persists, crosses a function boundary, or appears in a type signature that others must read.

-- Ephemeral: fine for a fold accumulator.
wordCounts :: String -> Array (Tuple String Int)
wordCounts = words >>> map (\w -> Tuple w 1) >>> ...

-- Persistent: use a record.
type WordCount = { word :: String, count :: Int }

wordCounts :: String -> Array WordCount

The cost of a record over a Tuple is one type declaration and named fields instead of fst/snd. The return is that every access site documents itself — entry.word versus fst entry — and that adding a third field later is a refactor, not a rewrite. Tuple scales to pairs; records scale to whatever the domain requires.

A useful heuristic: if you would name the components when explaining the code aloud, name them in the code.

85. Use comparing for custom sort and comparison

Data.Ord.comparing exists to eliminate the boilerplate of writing comparison lambdas.

Prefer:

sortBy (comparing _.age) users

Over:

sortBy (\a b -> compare a.age b.age) users

The comparing version is one expression instead of four, and it reads as English: "sort by comparing age." For compound sort keys, compose with <> on the Ordering monoid:

sortBy (comparing _.lastName <> comparing _.firstName) users

This sorts by last name first, breaking ties with first name — expressed as a single declarative statement rather than a nested comparison with fallback logic.

86. join <$> traverse is idiomatic

You have a function that returns a nested container — say, each node's children as an array — and you want to traverse a structure with it and flatten the result into a single container. There is no standard combinator for "traverse then flatten," but the idiom join <$> traverse does exactly this.

The naive approach builds a nested structure and then flattens it in a separate step:

-- lookupChildren :: NodeId -> Aff (Array NodeId)

-- Naive: traverse, then flatten.
allChildren :: Array NodeId -> Aff (Array NodeId)
allChildren ids = do
  nested <- traverse lookupChildren ids
  -- nested :: Array (Array NodeId)
  pure (join nested)

The idiomatic version fuses the two steps:

-- Idiomatic: join <$> traverse.
allChildren :: Array NodeId -> Aff (Array NodeId)
allChildren ids = join <$> traverse lookupChildren ids
-- traverse gives: Aff (Array (Array NodeId))
-- join flattens:  Aff (Array NodeId)

Recognise this pattern when you see it. It is the monadic generalisation of concatMap, lifted into an effectful context. (paf31, Nate Faubion)

87. Understand the PureScript String

PureScript's String is a JavaScript string. It is UTF-16 encoded, not a linked list of characters (Haskell's String), not a byte array (Rust's &str), and not a sequence of Unicode code points (Python 3's str).

This matters when you process text character by character. PureScript provides two modules:

import Data.String.CodeUnits as CU
import Data.String.CodePoints as CP

CU.length "hello" -- 5
CP.length "hello" -- 5

CU.length "\x1F600" -- 2 (surrogate pair)
CP.length "\x1F600" -- 1 (one code point)

If you use CodeUnits.take 1 on a string that starts with an emoji, you get half a surrogate pair — a meaningless fragment. Use CodePoints when correctness over the full Unicode range matters. Use CodeUnits when you are interoperating with JavaScript APIs that expect UTF-16 indices (such as DOM selection ranges).

Know which module you are importing. The functions have the same names.

88. Why does Data.String.contains need Pattern?

If you are new to PureScript, you may wonder why contains does not just take two strings:

-- This does not compile:
contains "needle" haystack

-- This does:
contains (Pattern "needle") haystack

The compiler will catch the mistake — Pattern is a newtype, not a type alias, so you cannot forget it. But the design choice is worth understanding. Pattern and Replacement exist so that replaceAll (Pattern "old") (Replacement "new") source cannot have its arguments silently swapped. Without the newtypes, both arguments would be String, and the transposition would compile happily.

This is entry 3 (Newtype what you mean) applied to the standard library. The library authors already made the decision for you — and you will see the same pattern in your own code once you start using newtypes for domain values.

VIII. Records, rows, and modules

Records and modules are the primary tools for organising code. Records give structure to data; modules give structure to namespaces. PureScript's row polymorphism makes records more flexible than in most languages, and its strict module system makes exports and imports more intentional.

89. Use records with named fields when three or more arguments share a type

When a function takes multiple arguments of the same type, the compiler cannot protect you from transposition. The caller can, if the arguments have names.

Prefer:

createUser :: { name :: String, email :: String, role :: String } -> Effect User

Over:

createUser :: String -> String -> String -> Effect User

The positional version permits createUser "admin" "alice@x.com" "Alice" and the compiler will not object. The record version makes each field self-documenting at the call site: createUser { name: "Alice", email: "alice@x.com", role: "admin" }.

This is not a blanket rule against curried functions. filter :: (a -> Boolean) -> Array a -> Array a benefits from currying because the two arguments have different types and partial application is natural. The rule applies when the types alone do not distinguish the arguments and human memory is the only safeguard.

For even stronger protection, combine records with newtypes: { name :: Name, email :: Email, role :: Role }.

90. Use row polymorphism instead of concrete record types in library APIs

PureScript's row polymorphism lets a function require specific fields without constraining the rest of the record. This is a feature worth using at module boundaries.

Prefer:

renderWidget :: forall r. { label :: String, onClick :: Effect Unit | r } -> HTML

Over:

type WidgetProps = { label :: String, onClick :: Effect Unit }

renderWidget :: WidgetProps -> HTML

The closed record forces every caller to construct exactly { label, onClick } and nothing more. The open record accepts any record that has at least those fields — callers with additional fields do not need to destructure and rebuild.

This matters most in libraries and shared modules where you cannot predict every calling context. Within a single application module, a closed record is often fine — you control both sides. The principle is: require what you need, accept what you are given.

91. Use record update syntax, not manual reconstruction

When you need to change one or two fields of a record, use update syntax. Do not rebuild the entire record by hand.

Prefer:

state { count = state.count + 1 }

Over:

{ count: state.count + 1
, name: state.name
, items: state.items
, loading: state.loading
}

The update syntax changes only the fields you name and preserves everything else. The manual reconstruction must list every field, and if you add a field to the record type later, the manual version silently fails to compile — or worse, if you are constructing a new record rather than updating, it compiles with the old default. Either way, you are doing bookkeeping the compiler should do for you.

For nested records, PureScript supports nested update syntax — no lenses required for simple cases:

setPersonPostcode :: PostCode -> Person -> Person
setPersonPostcode pc p = p { address { postCode = pc } }

In do blocks and Halogen handlers, combine this with the wildcard from entry 76: H.modify_ _ { loading = true }. One token for the record, one field updated, nothing else to read.

92. Use _ for record updates in modify

When updating state in Halogen or any context that takes a record-update function, use the wildcard _ instead of naming the record.

Prefer:

H.modify_ _ { loading = true }

Over:

H.modify_ \state -> state { loading = true }

The lambda version uses three tokens — \state -> state — to say "the record being updated." The wildcard says it in one. More importantly, the wildcard signals that nothing complex is happening: this is a field update, full stop. The lambda form looks identical to code that might do something more involved with state before updating it, and the reader must verify that it does not.

The record-update wildcard is PureScript-specific syntax. Newcomers often miss it because it does not exist in Haskell or most other ML-family languages. Once learned, it becomes second nature.

93. Use _.field for record access in map

When the body of a lambda is a single field access, use the accessor shorthand.

Prefer:

map _.name items

Over:

map (\item -> item.name) items

The shorthand _.name is a function from any record with a name field to that field's value. It is shorter, yes, but the real benefit is semantic: it says "extract this field" with no surrounding ceremony. The lambda version introduces a binding (item) that exists only to be immediately projected — the definition of a needless name.

The shorthand composes:

map _.address.city users

This would be map (\user -> user.address.city) users in the explicit form — four tokens of binding for zero information. Let the syntax carry the meaning.

94. Use an explicit lambda when the body goes beyond field access

Accessor shorthand (_.field) is for field extraction. The moment the body involves computation, conditionals, or references to multiple fields, write a named lambda instead.

-- Accessor shorthand: clean.
map _.name users

-- Lambda required: the body computes.
map (\item -> item.price * item.quantity) orders

-- Lambda required: multiple fields.
map (\u -> u.firstName <> " " <> u.lastName) users

The boundary is usually self-evident. If the body is more than a dotted path, you need a lambda — and the parameter name (item, u) gives the reader a handle on what is being transformed. Do not contort accessor shorthand to avoid naming a parameter; the name is the point.

95. Skip the newtype when the record field name already provides context

Entry 5 argues that newtypes are cheap and you should use them liberally. This is the necessary counterweight: not every value with an underlying type of Boolean or String needs a wrapper.

A following :: Boolean field inside a UserProfile record is unambiguous. The field name carries the semantic load. Wrapping it in newtype Following = Following Boolean adds a constructor and unwrapping ceremony to every access, with no improvement in type safety — there is no second Boolean field it could be confused with. (Thomas Honeyman)

-- The field name is sufficient.
type UserProfile = { name :: String, following :: Boolean, bio :: String }

-- Contrast: here newtypes earn their keep.
sendMessage :: UserId -> UserId -> MessageBody -> Aff Unit
-- Without newtypes, swapping sender and recipient is a silent bug.

The test from entry 5 still applies: "would swapping this value with another value of the same underlying type be a bug?" A single named field in a record fails that test — there is nothing to swap it with. Newtypes solve the positional confusion problem; named fields solve it differently.

See also entry 140 for the complementary case where the newtype is warranted.

96. Newtype everything that has different semantics from its base type

This is the affirmative case for newtypes, complementing entry 139's restraint. When a String is not just a string — when it is a UUID, an email address, a file path, a CSS class name — wrap it. The newtype costs nothing at runtime and prevents an entire category of mixups at compile time.

newtype EmailAddress = EmailAddress String
newtype UserId = UserId String

derive newtype instance Eq EmailAddress
derive newtype instance Eq UserId

-- The compiler will not let you send an email to a user ID.
sendVerification :: EmailAddress -> UserId -> Aff Unit

In larger applications, newtypes for records also improve the developer experience. Bare record types produce verbose, hard-to-follow type errors — the compiler prints the full row, which in a complex domain can span dozens of lines. A newtype gives the error a name. (Thomas Honeyman, Nate Faubion)

When uncertain, add the wrapper. The worst case is a few coerce or unwrap calls. The best case is a bug that never ships.

97. Extensible records for function arguments, closed records for domain models

PureScript's row polymorphism lets you write functions that accept records with extra fields:

fullName :: forall r. { first :: String, last :: String | r } -> String
fullName u = u.first <> " " <> u.last

This is excellent for utility functions and component interfaces — callers pass whatever record they have, and the function takes only what it needs. It is the PureScript equivalent of structural subtyping, and it composes well.

But domain models should be concrete. Define User, Order, Transaction as closed records or newtypes around closed records. Do not thread row variables through your entire domain layer.

-- Domain model: closed, concrete, documented.
newtype User = User
  { id :: UserId
  , email :: EmailAddress
  , name :: String
  , role :: Role
  }

-- Not this: extensible domain types push complexity to every consumer.
type User r = { id :: UserId, email :: EmailAddress, name :: String, role :: Role | r }

The extensible version forces every function that mentions User to carry and propagate the row variable. The syntactic duplication of spelling out your fields in a closed record is preferable to the cognitive overhead of tracking extensible type layers through a codebase. (joneshf)

98. Use explicit export lists

A module without an export list exports everything: public API, internal helpers, partially-applied constructors, and any re-exports you did not intend. This is rarely what you want.

-- Exports everything, including helpers the caller should not depend on.
module MyApp.Parser where

-- Exports exactly the public API.
module MyApp.Parser
  ( parse
  , ParseError(..)
  , ParseResult
  ) where

An explicit export list serves three purposes. It tells the reader what the module is for, without requiring them to scan the entire file. It lets you refactor internal functions freely, knowing that no downstream code depends on them. And it prevents accidental coupling — the kind that only surfaces when you try to move a helper function and discover six modules importing it.

Export data constructors with (..) when callers need to pattern match. Export only the type name when you want to preserve the ability to change the representation.

99. Use explicit imports or qualified imports

When you read head xs in a module with import Data.Array and import Data.List, you cannot tell which head is being called without checking the types. When you read Array.head xs, you can.

Prefer:

import Data.Array as Array
import Data.Map (lookup, insert)
import Data.String.CodeUnits (length)

Over:

import Data.Array
import Data.Map
import Data.String.CodeUnits

Open imports make the provenance of every name ambiguous. The compiler resolves it, but the reader must do extra work — or rely on an IDE — to do the same. Explicit imports also make unused dependencies visible: if you remove the last use of insert, the import stands out as dead code.

The Prelude is the most common open import, and its contents (map, bind, show, pure, unit) are ubiquitous enough that qualifying them adds noise. But even Prelude is not sacred — when working heavily with a library whose names clash with Prelude, it can be clearer to import Prelude explicitly and open-import the library instead. Use hiding to suppress specific Prelude names that clash rather than qualifying every use. The principle is always the same: make it obvious where each name comes from.

100. Separate data types from their operations

Define your ADTs and records in a Types module. Define operations in sibling modules that import Types.

src/
  MyApp/Types.purs       -- data types, newtypes, type aliases
  MyApp/Render.purs      -- imports Types, defines rendering functions
  MyApp/Validation.purs  -- imports Types, defines validation functions

This avoids circular dependencies — the most common module-structure headache in PureScript. Render and Validation can both depend on Types without depending on each other. If Render needs a validation helper, you can factor it out into a shared module that depends only on Types, rather than creating a cycle.

Type definitions change less often than the functions that operate on them. Separating them means that adding a new rendering function does not trigger recompilation of validation code, and vice versa. The initial overhead — one extra module, one extra import — is trivial. The structural benefit compounds as the codebase grows.

101. Distinguish configuration from state

Values that are set once at startup and never change — API base URLs, feature flags, locale, authentication tokens — belong in a Reader environment, not in mutable state.

-- Configuration: read-only, set at startup.
type Env = { apiBase :: String, locale :: String, features :: Features }

newtype AppM a = AppM (ReaderT Env Aff a)

-- Not this: mutable state that happens to never mutate.
type AppState = { apiBase :: String, locale :: String, ... , count :: Int }

Putting configuration in State invites accidental modification — a modify_ that changes apiBase compiles without complaint. It also complicates reasoning: when debugging unexpected behaviour, you must verify that the "configuration" fields have not been mutated, which should not be a question you need to ask.

ReaderT makes the guarantee structural. The environment is available everywhere via ask and asks, but no function can modify it. The distinction between "things that change" and "things that are fixed" is visible in the types.

102. Factor common fields out of ADT variants

If every constructor of a sum type carries the same field, that field belongs outside the sum.

-- Repeated: position appears in every constructor.
data Node
  = Element Position Name (Array Node)
  | TextNode Position String
  | Comment Position String

-- Factored: position is structural, content varies.
data NodeContent
  = ElementContent Name (Array Node)
  | TextContent String
  | CommentContent String

type Node = { position :: Position, content :: NodeContent }

Every Node still has a Position — it lives in the record wrapper, not in each constructor. TextContent and CommentContent carry only the data that varies; the position is always node.position, no pattern matching required.

The factored version makes the common structure visible in the type. You can write node.position directly, without a helper function that matches on every constructor to extract the same field. When you add a new constructor to NodeContent, the Node record still requires a position — you cannot forget it.

103. Keep modules under approximately 400 lines

A module that grows past this threshold is likely doing more than one thing. It accumulates responsibilities until no one can hold it in their head, and every change requires scrolling past unrelated code.

Split by responsibility. A data type and its core operations in one module; rendering functions in a sibling; serialisation in a third. PureScript's orphan-instance rule means a type and its instances must live together, but operations that use the type can live anywhere.

The number is not sacred — some modules are naturally larger (a component with many action handlers, a codec module for a complex API). The principle is: when you find yourself navigating within a module rather than reading it, it is time to split.

104. Write helpers liberally; export sparingly

Break complex functions into small, well-typed, unexported helpers. Each helper with a type signature is a checked assertion about an intermediate step — a waypoint where the compiler verifies your reasoning.

-- A single monolithic function: hard to test, hard to debug.
processData :: RawInput -> Effect Output
processData raw = do
  ...  -- 60 lines of interleaved parsing, validation, and transformation

-- Decomposed: each step is named, typed, and independently testable.
processData :: RawInput -> Effect Output
processData raw = do
  let parsed = parseFields raw
  validated <- validateFields parsed
  pure $ transformToOutput validated

-- These are not exported. They exist for clarity, not reuse.
parseFields :: RawInput -> ParsedFields
parseFields = ...

validateFields :: ParsedFields -> Effect ValidatedFields
validateFields = ...

transformToOutput :: ValidatedFields -> Output
transformToOutput = ...

The cost of an unexported helper is near zero: a few lines of code that the compiler checks and dead-code elimination removes if unused. The benefit is that when something goes wrong, the type error points to a small, named function rather than line 47 of an anonymous pipeline. Write as many as you need. Export only what the module's consumers require.

105. Structure modules by capability and domain

As a PureScript application grows beyond a handful of modules, directory structure becomes load-bearing. The Real World Halogen project demonstrates a structure that has aged well:

src/
  Api/           -- HTTP layer: request functions, response decoders
  Capability/    -- Type class interfaces: MonadLogger, ManageUser
  Component/     -- Reusable UI components
  Data/          -- Domain types, codecs, pure logic
  Page/          -- Page-level components (each route maps to a page)
  Store.purs     -- Global application state
  Main.purs      -- Entry point, wiring

This separates what the application can do (capabilities) from what it is (domain types) from how it looks (components and pages) from how it talks to the outside world (API). Each layer depends only on the layers below it.

The structure is not prescriptive for all applications. A compiler pass has different concerns than a web application. But the principle holds: group by responsibility, not by file type. Do not put all your types in Types.purs and all your functions in Utils.purs. (Thomas Honeyman)

See also entry 165 for the capability pattern that gives the Capability/ directory its purpose.

106. The capability pattern: type classes for effects, newtypes for implementations

Define your application's side effects as type class methods. Implement them in a production newtype. Swap in test implementations.

-- Capability: what the application can do.
class Monad m <= ManageUser m where
  getUser :: UserId -> m (Maybe User)
  saveUser :: User -> m Unit

class Monad m <= LogMessage m where
  logMsg :: LogLevel -> String -> m Unit

-- Production implementation.
instance ManageUser AppM where
  getUser uid = liftAff $ Api.fetchUser uid
  saveUser u  = liftAff $ Api.putUser u

instance LogMessage AppM where
  logMsg lvl msg = liftEffect $ Console.log (show lvl <> ": " <> msg)

Business logic is written against the type class constraints, not against AppM:

deactivateUser :: forall m. ManageUser m => LogMessage m => UserId -> m Unit
deactivateUser uid = do
  mUser <- getUser uid
  for_ mUser \user -> do
    saveUser (user { active = false })
    logMsg Info ("Deactivated user " <> show uid)

In tests, provide a mock implementation that records calls without performing them. The business logic is tested without HTTP requests, database connections, or console output. (Thomas Honeyman)

This is the ReaderT pattern from entry 149 taken to its logical conclusion: the monad abstraction separates business logic from effect plumbing entirely.

107. Follow namespace conventions: Data for data, Control for control, Node for Node.js

PureScript's module namespace conventions carry semantic weight. They tell the reader what category of abstraction a module provides before they open the file.

Data.* is for data structures, types, and pure operations on them. Data.Map, Data.Array, Data.Maybe. Control.* is for control flow abstractions — monads, applicatives, continuations. Control.Monad.Reader, Control.Alt. Effect.* is for effectful operations. Node.* is for Node.js-specific bindings.

The split is not arbitrary. There are genuinely two kinds of functor hiding behind the same type class. A data functor is a container — it holds many values, and map applies a function to each one. Data.Array, Data.Map, Data.List. A control functor wraps a single result with an effect, and bind sequences what happens next. Control.Monad.Reader, Control.Monad.State. In regular Haskell and PureScript the two coincide (every functor is both), but the namespace convention preserves the conceptual distinction. A useful heuristic: if the abstraction answers "what is in here?", it belongs in Data. If it answers "what do I do next?", it belongs in Control. (Arnaud Spiwack, "A Tale of Two Functors")

Do not put your data structure in Control.MyThing. Do not put your effect wrapper in Data.MyEffect. The namespace is not a filing system — it is a signal. When a reader sees import Control.Monad.MyTransformer, they expect a monad transformer. When they see import Data.MyCollection, they expect a data structure with pure operations.

For application code, your top-level namespace is your project name: MyApp.Data.User, MyApp.Api.Client, MyApp.Component.Header. The conventions apply within your namespace just as they do in the ecosystem. (Official Style Guide)

IX. PureScript is not Haskell

PureScript borrows much from Haskell — syntax, type classes, algebraic data types — but the languages differ in ways that matter daily. This section catalogues the differences that trip up Haskell programmers most often. If you have never written Haskell, you may skip this section without loss.

108. Unused bindings are not free in a strict language

PureScript evaluates strictly, left to right. In Haskell, let x = expensiveComputation in if flag then x else 0 never evaluates expensiveComputation when flag is false. In PureScript, it always does.

-- In PureScript, both branches of the let are evaluated.
let
  expanded = buildFullTree dataset     -- always runs
  summary  = summarize dataset         -- always runs
in if detailed then expanded else summary

-- Evaluate only what you need.
if detailed
  then buildFullTree dataset
  else summarize dataset

Code ported from Haskell or written with Haskell intuitions can silently do more work than intended. Every let binding is evaluated at the point of definition, not at the point of use. If only one of several bindings is needed, move the others into the branch that uses them or compute them lazily with an explicit thunk.

109. Infinite structures do not work

PureScript's strict evaluation means there is no Data.List.iterate that produces values on demand. An expression like iterate (_ + 1) 0 would attempt to build an infinite list immediately and never terminate.

If you want a stream, you need an explicit lazy type (such as Data.Lazy or a lazy list library) or a generator pattern that produces values one at a time. Do not port Haskell idioms that rely on lazy spine evaluation — they will hang or exhaust memory.

110. Choose foldl for strict accumulation

In Haskell, foldr is often preferred because laziness lets it short-circuit and work on infinite structures. In PureScript, foldl is the natural choice for strict left-to-right accumulation, and foldr should be chosen only when the algebra requires right-association (building a list, for instance).

Picking the wrong fold does not produce wrong results — but it can produce unnecessary intermediate allocations. When accumulating a sum, a count, or any strict value, use foldl. Reserve foldr for cases where the combining function is non-strict in its second argument or where the result must be right-associated.

111. Write explicit forall

PureScript does not silently introduce type variables. If a type signature mentions a, you must write forall a. to bring it into scope. There is no implicit universal quantification.

-- Does not compile: `a` is not in scope.
identity :: a -> a
identity x = x

-- Compiles.
identity :: forall a. a -> a
identity x = x

This is one of the first surprises for Haskell programmers, where identity :: a -> a works without ceremony. In PureScript, the explicitness is deliberate — it makes the binding site of every type variable visible, which matters more as signatures grow complex with constraints, higher-rank types, and visible type applications.

When you see a compiler error about an undefined type, and the name in question is a lowercase single letter, you almost certainly forgot a forall.

112. Use <<< for composition, not .

In Haskell, (.) is function composition. In PureScript, the dot is reserved for record access and module-qualified names. Composition uses <<< (right-to-left) and >>> (left-to-right).

-- PureScript composition.
normalise :: String -> String
normalise = trim <<< toLower

-- Or, reading left-to-right:
normalise = toLower >>> trim

There is nothing more to say. The syntax is different; the concept is identical. If you find yourself writing f . g and getting a confusing parse error about records, this is why.

113. Number literals are not overloaded — and the numeric hierarchy is different

In Haskell, 1 has type Num a => a — it can be an Int, an Integer, a Double, or any other numeric type. In PureScript, 1 is an Int and 1.0 is a Number. There is no Num class and no fromInteger.

-- Does not compile: 1.0 is Number, not Int.
addOne :: Int -> Int
addOne x = x + 1.0

-- Explicit conversion when needed.
scale :: Int -> Number
scale n = toNumber n * 2.5

This rigidity avoids an entire class of ambiguity errors that Haskell programmers know well ("Ambiguous type variable 'a' arising from the literal '1'"). The trade-off is that you must convert explicitly between Int and Number with toNumber and round/floor/ceil.

The differences go deeper than literals. PureScript's numeric type class hierarchy — Semiring, Ring, CommutativeRing, EuclideanRing, Field — is more algebraically principled than Haskell's Num. Each class corresponds to a genuine algebraic structure with laws. Semiring gives you (+) and (*) with identity elements. Ring adds negation. EuclideanRing adds integer division and mod. Field adds real division. There is no grab-bag class that bundles unrelated operations together.

In practice, this means: (+) requires Semiring, (-) requires Ring, div and mod require EuclideanRing, and (/) requires Field. When you see a constraint like EuclideanRing a =>, you know exactly which operations are available and which algebraic laws they satisfy. The hierarchy is more to learn upfront, but it eliminates the "why does Num require abs and signum?" puzzlement that Haskell programmers accept as normal.

114. Operator sections use _, not partial application syntax

Haskell's operator sections let you write (+ 2) to mean "a function that adds 2 to its argument." PureScript uses an underscore placeholder instead.

-- PureScript operator sections.
addTwo    = (_ + 2)
divideBy  = (10 / _)
wrapInDiv = HH.div_ <<< pure

-- This does NOT work:
addTwo = (+ 2)  -- parse error

The underscore syntax is more general than Haskell's: it works uniformly for both left and right sections and extends to record access (_.name) and function application (f _ 3). The price is two extra characters. The benefit is that the section is never ambiguous — you can always see which argument is missing.

115. Ensure stack safety with tailRecM

In Haskell, monadic recursion is stack-safe by default because laziness defers the frames. PureScript is strict. A recursive monadic computation that recurs a thousand times will build a thousand stack frames and may overflow.

-- Unsafe: each iteration adds a stack frame.
countDown :: Int -> Effect Unit
countDown 0 = pure unit
countDown n = do
  log (show n)
  countDown (n - 1)

-- Safe: tailRecM runs in constant stack.
countDown :: Int -> Effect Unit
countDown = tailRecM go
  where
  go 0 = do
    pure (Done unit)
  go n = do
    log (show n)
    pure (Loop (n - 1))

The MonadRec class and tailRecM provide a trampolining mechanism: instead of recursing directly, you return Loop to continue or Done to finish. The runtime drives the loop without growing the stack.

This matters whenever your recursion depth is proportional to data size rather than code structure. A three-branch case expression is fine. A fold over ten thousand elements needs tailRecM or one of the library combinators (foldRecM, whileM) that use it internally. If you are porting Haskell code that uses forever or deep >>= chains, this is the first thing to check.

116. Mutual recursion defeats TCO

The PureScript compiler performs tail-call optimisation on self-recursive functions — a function that calls itself in tail position becomes a JavaScript while loop. But if function A calls function B which calls function A, neither is self-recursive, and no optimisation occurs.

-- No TCO: mutual recursion builds stack frames.
isEven :: Int -> Boolean
isEven 0 = true
isEven n = isOdd (n - 1)

isOdd :: Int -> Boolean
isOdd 0 = false
isOdd n = isEven (n - 1)

-- TCO-friendly: fuse into a single self-recursive function.
isEven :: Int -> Boolean
isEven = go true
  where
  go acc 0 = acc
  go acc n = go (not acc) (n - 1)

The general technique is to merge the mutually recursive functions into one function with a tag parameter (here, the boolean accumulator) that distinguishes what was formerly a call to isEven from a call to isOdd. The result is a single self-recursive function the compiler can optimise.

For more complex cases where fusion is awkward, tailRecM (entry 18) works as well: represent the choice of "which function to call next" as part of the Loop value.

117. Phantom types and smart constructors replace most GADTs [Haskell]

Haskell programmers arriving in PureScript quickly notice the absence of GADTs. The instinct is to reach for elaborate type-class encodings that simulate them. In most cases, a phantom type parameter with smart constructors does the job — compiles faster, produces better error messages, and can be read by a colleague who has not studied the Hasochism paper.

In Haskell you might write:

data Expr a where
  LitInt :: Int -> Expr Int
  Add    :: Expr Int -> Expr Int -> Expr Int

In PureScript:

data Expr (a :: Type) = LitInt Int | LitBool Boolean | Add (Expr Int) (Expr Int)

litInt :: Int -> Expr Int
litInt = LitInt

litBool :: Boolean -> Expr Boolean
litBool = LitBool

add :: Expr Int -> Expr Int -> Expr Int
add = Add

The phantom parameter does not appear in the data constructors, but the smart constructors enforce the relationship. Pattern matching still requires care — you are trading a compiler guarantee for a module-boundary discipline — but for most DSLs this is sufficient.

118. Use continuation-passing style to encode existential types [Haskell]

If you hit a wall trying to express existential types in PureScript, this is the entry to bookmark. The technique is worth learning once — but you may not need it on day one.

The problem: Haskell's ExistentialQuantification or GADTs let you write data SomeShow = forall a. Show a => SomeShow a — a type that hides the concrete type while retaining a constraint. PureScript does not have this syntax. If you need a heterogeneous collection or a type that erases its concrete representation while preserving a constraint, the idiomatic alternative uses rank-2 types in an encoding known as continuation-passing style (CPS).

The technique in detail. In continuation-passing style, instead of returning a result directly, a function takes an extra argument — a continuation — that says what to do with the result. The function calls the continuation instead of returning. This indirection is the key to the encoding: you never store the hidden value directly. Instead, you store a function that accepts a handler (the continuation) and applies it to the hidden value internally.

The handler must be polymorphic — it must work for any type satisfying the constraint — so the concrete type never escapes. The rank-2 quantification (forall inside the argument) is what enforces this.

Here is the pattern applied to a Foldable container whose concrete type is hidden:

-- "I have a Foldable container of Ints, but I won't tell you which one."
newtype SomeFoldable = SomeFoldable (forall r. (forall f. Foldable f => f Int -> r) -> r)

Reading this type from the outside in: SomeFoldable wraps a function. That function takes a continuation k of type forall f. Foldable f => f Int -> r — meaning k must work for any Foldable, not a specific one — and produces an r. Inside, the function applies k to the concrete container it is hiding.

The smart constructor captures a concrete container and seals it behind the rank-2 boundary:

mkSomeFoldable :: forall f. Foldable f => f Int -> SomeFoldable
mkSomeFoldable fa = SomeFoldable \k -> k fa
-- `fa` is concrete here (Array, List, Set, etc.), but `k` cannot inspect which.

To use the hidden value, you provide a function that works for any Foldable. The continuation you pass in is applied to the hidden container — you get to operate on it, but you never learn its concrete type:

sumHidden :: SomeFoldable -> Int
sumHidden (SomeFoldable run) = run \fa -> foldl (+) 0 fa
-- `fa` could be an Array, a List, or a Set — the caller never finds out.

No unsafeCoerce, no Foreign, no runtime tags. The rank-2 type does the work. The compiler guarantees that the concrete type cannot leak.

When you need a heterogeneous collection — "a list of things that can each be folded, but with different concrete container types" — this is the PureScript answer. The pattern is worth learning once; it appears throughout the ecosystem.

119. Use sum types directly for typed command/message patterns [Haskell]

When you want different payload types for different commands — Command Insert carrying an InsertPayload, Command Delete carrying a DeletePayload — the Haskell instinct is to index the command type by a phantom and use GADT matching to eliminate it. In PureScript, use a plain sum type:

data Command
  = Insert InsertPayload
  | Delete DeletePayload
  | Update UpdatePayload

No phantom parameter, no type-level tag, no class instances to recover the payload type. The sum type is total, exhaustive, and obvious. Pattern matching gives you the payload directly, and the compiler ensures you handle every case.

The general principle: when the simpler encoding covers your use case, prefer it. PureScript's sum types are expressive enough for most command and message patterns, and the directness pays off in readability and error messages. If you have used GADTs for this in Haskell, the plain sum type may feel like a step down — but the loss is smaller than it appears, and the gain in simplicity is immediate.

120. Derived Ord for records compares fields in alphabetical label order

When the compiler derives an Ord instance for a record type, it compares fields in alphabetical order by label name — not in the order they appear in the declaration.

type Person = { zipCode :: String, age :: Int, name :: String }

derive instance Ord Person
-- Compares by: age, then name, then zipCode.
-- NOT by: zipCode, then age, then name.

This is consistent with PureScript's treatment of records as row types, where label order is semantic, not syntactic. But it surprises programmers who expect declaration order to matter, particularly those coming from Haskell where field order in a data declaration determines derived comparison order.

If you need a specific comparison order — compare by age first, then by name — write the instance by hand. Do not rely on renaming fields to sort alphabetically into your preferred order; that is a maintenance trap waiting for the next person who adds a field. (jpvillaisaza)

121. Boolean operations (||, &&) are non-strict — an exception to PureScript's strict semantics

Entry 8 establishes that PureScript is strict. Boolean || and && are the exception: the compiler implements short-circuit evaluation for Boolean's HeytingAlgebra instance. false && expensiveCheck does not evaluate expensiveCheck.

-- Short-circuits: the second condition is not evaluated.
isValid :: User -> Boolean
isValid user = isActive user && hasPermission user

But this guarantee applies only to Boolean. The HeytingAlgebra type class is more general, and other instances are not required to short-circuit:

-- Generic code: no short-circuit guarantee.
bothTrue :: forall a. HeytingAlgebra a => a -> a -> a
bothTrue x y = x && y
-- If `a` is not Boolean, `y` may be evaluated even if `x` is "false-like."

If you write generic code over HeytingAlgebra, do not assume the right-hand side is unevaluated when the left-hand side determines the result. The short-circuit behaviour is a special case of the Boolean instance, not a law of the class. (eric-corumdigital)

X. PureScript compiles to more than JavaScript

The JavaScript backend is the most mature and widely used, but PureScript also targets Erlang, Python, Lua, and other platforms. Advice that assumes JavaScript — 'use Array for performance', 'the FFI is a .js file' — may not transfer. The entries here remind you to think about the language, not just one backend.

122. Note runtime environment assumptions in your README

A PureScript library that calls process.argv will fail silently in the browser. A library that calls document.querySelector will crash in Node. These are not type errors — the compiler cannot catch them.

If your library assumes a specific runtime environment, say so in the first paragraph of the README. Not in a "Compatibility" section that the reader scrolls past. Not in a footnote. At the top.

# purescript-node-streams

Node.js bindings for readable and writable streams.

**This library requires a Node.js runtime.** It will not work in browsers or other JavaScript environments.

A consumer who installs your library for the wrong platform gets cryptic FFI errors — TypeError: Cannot read property 'createReadStream' of undefined — not a helpful message. The README is the only firewall you have. (Official Style Guide)

123. PureScript is industrially focused, not a PL research vehicle

PureScript is a language designed for building software, not for exploring the frontiers of type theory. This is a deliberate choice with practical consequences.

Stability is a high priority. Features that require large-scale breaking changes across the ecosystem are unlikely to be accepted, regardless of their theoretical merit. The language prefers fewer, more powerful features to many special-purpose ones. If a need can be addressed downstream of the compiler — in a library, a code generator, a build tool — it probably should be.

This means some features that PureScript could have, it chooses not to. Dependent types, linear types, effect rows — these are active areas of PL research, and PureScript's governance has consistently prioritised the working programmer over the language enthusiast. The language is expressive enough to build complex systems and simple enough to onboard working developers.

For users, the implication is: work with the language as it is. If you find yourself fighting the type system to encode an invariant it was not designed to express, consider whether a simpler encoding — a smart constructor, a runtime check at the boundary, a convention documented in a comment — might serve better. The goal is working software, not a proof of concept. (Gary Burgess, Nate Faubion, Thomas Honeyman)

XI. Parsing, codecs, and round-tripping

Data enters your program as untyped bytes and leaves as untyped bytes. The transformation between external representation and internal types should happen at the boundary, happen once, and be verifiable. Bidirectional codecs, parser combinators, and optics are the tools for this work.

124. Use purescript-parsing for structured parsing, not regex

A regular expression can validate a pattern. A parser combinator can extract structure, report precise error positions, and compose with other parsers.

-- Regex: validates shape but extracts nothing typed
isValidDate :: String -> Boolean
isValidDate = test (unsafeRegex "^\\d{4}-\\d{2}-\\d{2}$" noFlags)

-- Parser: validates, extracts, and composes
dateParser :: Parser String Date
dateParser = do
  year  <- intDigits 4
  _     <- char '-'
  month <- intDigits 2
  _     <- char '-'
  day   <- intDigits 2
  case mkDate year month day of
    Nothing -> fail "Invalid date"
    Just d  -> pure d

The parser version is longer but does more: it produces a Date, not a Boolean. It rejects 2024-13-45 where the regex accepts it. And it composes — you can embed dateParser inside a larger parser for log lines, CSV rows, or configuration files without rewriting anything.

Use regex for quick guards at the boundary — "does this look like an email?" Use purescript-parsing when you need to turn text into data.

125. Use codec for JSON, not hand-written decoders

A hand-written EncodeJson instance and a hand-written DecodeJson instance are two independent pieces of code that must agree on field names, nesting structure, and handling of optional values. They will disagree eventually.

purescript-codec-argonaut defines a single bidirectional codec that handles both directions:

import Data.Codec.Argonaut as CA
import Data.Codec.Argonaut.Record as CAR

userCodec :: JsonCodec User
userCodec = CA.object "User" $ CAR.record
  { name: CA.string
  , email: CA.string
  , role: roleCodec
  }

The codec is the single source of truth. If it encodes a field as "name", it decodes from "name". Roundtripping is guaranteed by construction, not by the discipline of two separate authors (or the same author on two different days).

When you need custom handling — a sum type encoded as a tagged string, a date encoded as ISO 8601 — you write a codec for that type once, and it composes into every record and array codec that uses it.

126. Decode at the boundary, work with types internally

JSON, Foreign values, URL query parameters, and localStorage strings are external representations. They belong at the edge of your application — the point where data enters or leaves. Inside the boundary, everything should be typed.

-- At the boundary: decode once
fetchTasks :: Aff (Either JsonDecodeError (Array Task))
fetchTasks = do
  response <- Fetch.get "/api/tasks"
  pure $ decode (CA.array taskCodec) response.body

-- Inside the boundary: work with typed values
filterOverdue :: Array Task -> DateTime -> Array Task
filterOverdue tasks now = filter (\t -> t.due < now) tasks

If filterOverdue took Json and decoded internally, you would be decoding the same payload every time it was called, handling decode errors in a function that has nothing to say about malformed JSON, and hiding the fact that the real dependency is on Task, not Json.

Push the parse to the outermost layer. If a function three levels deep needs to decode JSON, the boundary is in the wrong place.

A pragmatic exception. For very large JSON payloads on JavaScript backends, full decoding into PureScript types can carry a real performance cost. In these cases, you might pragmatically skip full parsing — accessing fields directly from the raw JSON via FFI or Foreign — and accept the runtime risk. If you do this, treat it as a conscious, documented decision: comment why you are bypassing the codec, which fields you are accessing unsafely, and what breaks if the shape changes. This is a performance escape hatch, not a default.

127. JSON codecs should be values, not type class instances

The EncodeJson and DecodeJson type classes from argonaut are convenient — encodeJson myValue picks up the instance automatically. But this convenience has costs that grow with your codebase.

First, orphan-instance pressure. If your type is defined in one package and your encoding strategy in another, you either create orphan instances or couple your domain types to a serialisation library. Second, invisibility. When encoding is implicit, the reader cannot tell which encoding is in use without chasing the instance chain. Third, inflexibility. A type can have only one instance, but real systems often need multiple encodings — one for the API, one for the database, one for logging.

-- Codec value: explicit, composable, bidirectional.
import Data.Codec.Argonaut as CA

userCodec :: JsonCodec User
userCodec = CA.object "User" $ CA.recordProp (Proxy :: _ "name") CA.string
  <<< CA.recordProp (Proxy :: _ "email") CA.string
  <<< CA.recordProp (Proxy :: _ "role") roleCodec

-- The codec is a value. You can have as many as you need.
userApiCodec :: JsonCodec User    -- for the REST API
userLogCodec :: JsonCodec User    -- for structured logs, omitting PII

purescript-codec-argonaut gives you bidirectional codec values that are explicit at every call site, composable via ordinary function composition, and guarantee that encode and decode agree by construction. (Gary Burgess)

See also entry 102 on not using show for serialisation — the same principle of making encoding decisions visible and deliberate.

128. Optics: a lens is a getter and a setter that agree

If you find yourself writing paired functions — getField and setField, or readNested and updateNested — you have half a lens. The profunctor-lenses library gives you composable access paths into nested structures, and the two halves are guaranteed to agree because they are one value, not two.

-- Without optics: manual nesting.
updateCity :: String -> Company -> Company
updateCity city company =
  company { headquarters = company.headquarters { address = company.headquarters.address { city = city } } }

-- With optics: compose the path.
_city :: Lens' Company String
_city = prop (Proxy :: _ "headquarters") <<< prop (Proxy :: _ "address") <<< prop (Proxy :: _ "city")

updateCity :: String -> Company -> Company
updateCity = set _city

Start with record lenses (prop), _Just for Maybe, and _Left/_Right for Either. These cover the majority of real-world nesting. Prisms, isos, and traversals are powerful but rarely needed in application code — reach for them when the simpler tools fall short, not before.

The deeper value of optics is composability. A lens into a record field and a prism into a sum type constructor compose with <<< into an optic that focuses through both layers. You build complex access paths from simple, tested pieces, and each piece can be reused independently.

XII. Omit needless code

Strunk and White's most famous rule is 'Omit needless words.' The same principle applies to code. Every unnecessary binding, redundant pattern, or verbose combinator chain is a distraction from the intent. PureScript's concise syntax rewards brevity — use it.

129. Do not name a value you immediately pass to one function

A let binding is documentation. It says: "this intermediate value has a role worth naming, or it will be used more than once." When neither is true, the binding is clutter.

Prefer:

do
  user <- fetchUser id
  log user.name

Over:

do
  user <- fetchUser id
  let name = user.name
  log name

The binding name exists for one line. It is consumed immediately and never referenced again. The reader must track it anyway — scanning ahead to confirm it is not used a second time, checking that it means what they think it means. Passing user.name directly eliminates this overhead.

This does not apply when the name genuinely clarifies intent. let cutoff = 0.5 is fine even if used once, because cutoff tells the reader something that 0.5 does not. The test is not "how many times is it used?" but "does the name add information?"

130. The principle, stated

Naming a value is a promise that the name matters. Every binding asks the reader to remember it, track its scope, and consider whether it will appear again. In a language with good syntax for anonymous operations — lambda-case, record-update wildcards, accessor shorthand, point-free composition — many of these names are unnecessary. They exist not because the author chose them but because the author did not notice they could be omitted. The discipline is not to write the shortest code, but to write code where every name earns its place. When the structure of the expression already tells you what is happening — when position, type, and context are sufficient — let the syntax speak and keep the namespace clean. Omit needless names.

XVI. Miscellany

131. Sometimes you just need a let

If the compiler is telling you something needs to be pure because it is in a do block, consider whether it might only need a let. Beginners often reach for x <- pure (f y) because everything else in the block uses <- and a plain declaration seems to require it. It does not — let works directly inside do blocks for pure bindings.

Prefer:

do
  response <- fetchData url
  let parsed = parseResponse response
  saveToCache parsed

Over:

do
  response <- fetchData url
  parsed <- pure (parseResponse response)
  saveToCache parsed

Every <- in a do block signals "this is where an effect happens." let says what it is: a pure binding. The reader does not need to look inside the parentheses to confirm that nothing effectful is going on.

132. Avoid explicit recursion; use higher-order functions

Most recursive patterns over data structures are already captured by standard combinators: map, filter, foldl, foldr, traverse, unfold, mapAccumL. Explicit recursion is harder to read, easier to get wrong, and — in a strict language — liable to blow the stack.

-- Higher-order: intent is visible, stack safety is inherited.
sumPositive :: Array Int -> Int
sumPositive = filter (_ > 0) >>> sum

-- Explicit recursion: the reader must verify termination and accumulator handling.
sumPositive :: Array Int -> Int
sumPositive = go 0
  where
  go acc arr = case Array.uncons arr of
    Nothing -> acc
    Just { head, tail } ->
      if head > 0 then go (acc + head) tail
      else go acc tail

Reach for explicit recursion only when no standard combinator fits — tree traversals with complex accumulation, interleaved effects with early termination, or algorithms where the recursive structure is the point. When you do write explicit recursion, use tailRecM or the MonadRec class to guarantee stack safety.

133. Write the simplest code that the types permit

If the compiler accepts your code and the meaning is clear to a reader, the code is good enough. Do not add type-level machinery to enforce an invariant the code already maintains. Do not abstract over a pattern that occurs once. Do not reach for a monad transformer when a function argument will do.

Simplicity in PureScript is not simplicity in JavaScript. A sum type with four constructors, a newtype with a smart constructor, an ado block that validates five fields — these are precision, not complexity.

The temptation runs the other direction. PureScript offers enough abstraction machinery to build cathedrals. But generality that serves no current need is speculation, and speculation has a carrying cost: every reader must understand the abstraction to understand the code.

Write a concrete function. When a second use case appears, extract the commonality. When a third appears, consider a type class. The simplest code that the types permit is the least sophisticated code that still captures every distinction the problem demands.

134. Use $ and # to reduce parentheses, but do not chain excessively

$ (apply) and # (pipe) exist to reduce nested parentheses. One or two applications improve readability. A long chain trades one problem for another.

-- Good: one $ eliminates a nesting level.
Map.lookup key $ Map.fromFoldable pairs

-- Good: # for a left-to-right pipeline.
pairs # Map.fromFoldable # Map.lookup key

-- Too much: the reader counts operators instead of parentheses.
f $ g $ h $ i $ j $ k x

For pipelines longer than two or three steps, use >>> composition with a named function, or break the chain into let bindings. The goal is to reduce the reader's working memory, not to demonstrate that parentheses are unnecessary.

$ reads right to left; # reads left to right. Within a codebase, pick a prevailing direction and stay consistent. Mixing the two in a single expression is almost always confusing.

135. Avoid dead code and commented-out blocks

Delete what you do not use. Version control remembers what you have deleted; your codebase should not.

Commented-out code is a trap. It rots faster than live code because the compiler never checks it. When dependencies change, module names shift, or type signatures evolve, the commented block falls silently out of sync. A reader encountering it cannot know whether it is a plan, a memory, a debugging aid, or an oversight. In every case, it is noise.

The same applies to unused imports, unreachable branches, and functions that nothing calls. Each is a false signal — an assertion that this code matters when it does not. Remove it. If you need it again, git log is a better archive than a comment.

136. Use purs-tidy and do not fight it

purs-tidy is the community formatter for PureScript. Run it. Configure your editor to run it on save. Do not manually adjust its output.

Consistent formatting across the ecosystem means you can read anyone's code without adjusting to their whitespace preferences, open a pull request without noise from reformatting, and focus code review on substance rather than style. The specific choices purs-tidy makes are less important than the fact that everyone makes the same ones.

If you disagree with a formatting decision, consider whether the disagreement is worth the cost of divergence. It almost never is.

XIII. The build

Spago is PureScript's build tool and package manager. It is opinionated about project structure and dependency management, and working with those opinions — rather than against them — saves time and frustration.

137. Use a package set for reproducible builds

A package set is a fixed snapshot of the registry — a known-good collection of packages at specific versions, tested together. Most projects should use one.

workspace:
  packageSet:
    registry: 63.2.0

Every developer and CI run resolves to the same versions. If a package was published after your snapshot, it does not exist as far as Spago is concerned — bump the registry version or add the package to extraPackages.

138. Use the solver when the package set is not enough

The solver resolves version ranges dynamically, like npm or Cargo, finding versions that satisfy all constraints. It activates when you add extraPackages to your workspace.

workspace:
  packageSet:
    registry: 63.2.0
  extraPackages:
    some-new-lib:
      git: https://github.com/user/some-new-lib.git
      ref: main

The solver gives flexibility — you can use packages or versions not in the set. The trade-off is that resolution is no longer fully deterministic from the package set alone; the lock file becomes essential for reproducibility.

The most common source of "package not found" errors is confusion about which mode is active. If you are on a package set and a package is missing, either bump the registry version or add the package to extraPackages.

139. Use workspace.extraPackages for local and unpublished dependencies

When you depend on a local checkout, a git repository, or a package not yet in the registry, add it under workspace.extraPackages in your root spago.yaml. Do not hack the package set, symlink directories into .spago, or copy source files into your tree.

workspace:
  packageSet:
    registry: 63.2.0
  extraPackages:
    # A local checkout (sibling directory):
    hylograph-canvas:
      path: ../purescript-hylograph-libs/canvas

    # A git dependency at a specific commit:
    some-experimental-lib:
      git: https://github.com/user/some-experimental-lib.git
      ref: a1b2c3d
      subdir: lib

The path: form is for local directories — monorepo siblings, packages under active development, forks with local patches. The git: form is for remote repositories pinned to a ref. Both integrate cleanly with the lock file and dependency resolution. Both are visible in one place, not scattered across shell scripts or build hacks.

When the package is eventually published to the registry and appears in a package set you adopt, remove the extraPackages entry. The override has served its purpose.

140. Keep spago.yaml minimal and let the lock file do its job

spago.yaml declares your intent: which packages you depend on, which version ranges you accept, which registry snapshot you use. spago.lock records what was actually resolved: exact versions, exact hashes, the full dependency tree.

# spago.yaml — declare intent:
package:
  name: my-app
  dependencies:
    - aff
    - halogen
    - argonaut-codecs

Check in the lock file. Do not pin exact versions in spago.yaml unless you have a specific, documented reason — that is the lock file's job. Pinning versions in spago.yaml defeats the flexibility of range resolution and creates two sources of truth about which version is in use.

When you run spago install, Spago resolves dependencies and updates the lock file. When a colleague clones the repo and runs spago build, the lock file ensures they get the same versions you did. This is the same contract as package-lock.json or Cargo.lock. Trust it.

141. Spago supports monorepo workspaces

If you are developing tightly coupled packages — a library and the application that uses it, or a family of related libraries — Spago's workspace feature lets you manage them in a single repository. A root spago.yaml defines the workspace — the package set, extra packages, and shared configuration. Sub-directories each have their own spago.yaml with a package: stanza declaring their name, dependencies, and source globs.

# Root spago.yaml
workspace:
  packageSet:
    registry: 63.2.0
  extraPackages: {}

# packages/canvas/spago.yaml
package:
  name: hylograph-canvas
  dependencies:
    - effect
    - web-dom
  publish:
    version: 0.3.0
    license: MIT

Each package can depend on its siblings by name, and Spago resolves the internal dependency graph. You get one lock file, one package set, and a single spago build that builds everything.

This is one option for organising multi-package projects. Separate repositories with their own package sets and CI pipelines are a reasonable alternative, especially when packages have independent release cycles and maintainers. The workspace approach works well when packages evolve together and you want to test cross-cutting changes in a single commit.

142. spago bundle vs spago build: know the difference

spago build compiles PureScript source to ES modules in the output/ directory. Each PureScript module becomes a directory with an index.js file. This is sufficient for libraries, for Node applications that can consume ES module imports, and for any downstream tool that handles bundling itself.

spago bundle does everything spago build does and then runs esbuild to produce a single JavaScript file — a bundle suitable for loading in a browser via a <script> tag, or for deploying as a single-file Node script.

# spago.yaml bundle configuration for a browser app:
package:
  name: my-app
  bundle:
    module: Main
    outfile: dist/bundle.js
    platform: browser

If your HTML loads bundle.js, you need spago bundle. If you are writing a library consumed by other PureScript packages, spago build is sufficient and spago bundle is unnecessary. Getting this wrong produces either "module not found" errors in the browser (because the browser cannot resolve ES module imports from output/) or an unnecessarily large bundle in Node (because esbuild inlined dependencies you did not need to inline).

143. Browser bundles need platform: browser

When bundling for the browser, your spago.yaml bundle configuration must include platform: browser. Without it, esbuild defaults to the Node platform and may leave in Node-specific imports — fs, path, process, buffer — that do not exist in the browser.

# Correct:
package:
  bundle:
    module: Main
    platform: browser
    outfile: dist/app.js

# Missing platform — will default to node:
package:
  bundle:
    module: Main
    outfile: dist/app.js

The resulting error is often cryptic: a ReferenceError: process is not defined or require is not a function at runtime, not at build time. You stare at your PureScript source looking for the Node dependency and find nothing, because the import was introduced by a transitive JavaScript dependency that esbuild chose not to polyfill. The fix is one line in the config.

144. Set bundle.module to your entry point

spago bundle needs to know which module contains your application's main function. Set bundle.module in your spago.yaml:

package:
  bundle:
    module: Main
    outfile: dist/bundle.js
    platform: browser

If omitted, the bundler may produce an empty file, a file that defines modules but never calls main, or an error that does not clearly indicate the problem. The fix is always the same: tell the bundler where to start.

This is especially easy to overlook in monorepo setups where each package has its own entry point. Each package that produces a bundle needs its own bundle.module declaration.

145. Do not import from output/ in your source

The output/ directory is a build artifact generated by the PureScript compiler. It is not part of your source tree.

-- Do not do this:
foreign import myHelper :: forall a. a -> Effect Unit
-- with an FFI file that says:
-- import { someFunction } from "../../output/Other.Module/index.js"

-- Do this:
import Other.Module (someFunction)

Importing from output/ in your PureScript source or FFI files creates a circular dependency between the build system and your code. The output/ directory may not exist yet when the compiler first runs. Its internal structure is a compiler implementation detail that may change between PureScript versions. And it defeats incremental compilation, because changes to one module now invalidate another through a path the compiler does not track.

Use PureScript imports for PureScript dependencies. If you need to call JavaScript, use the FFI mechanism — a .js file alongside your .purs file. Let the compiler and bundler resolve the paths.

146. Clear output/ when things make no sense

Incremental compilation is a tremendous convenience until it produces a stale artifact. This happens most often after renaming modules, changing the package set, switching git branches that reorganised the source tree, or upgrading the compiler.

The symptoms are distinctive: duplicate module errors for modules that exist only once, type errors that contradict what you can see in the source, "module not found" for a module you just created. The common thread is that the error does not match reality.

rm -rf output .spago
spago build

This costs thirty seconds on a clean build and saves thirty minutes of debugging a phantom. It is not a sign of a broken tool — incremental compilation systems in every language have this failure mode. The important thing is to recognise the symptoms and reach for the fix without guilt.

If you find yourself clearing output/ routinely (more than once a week), something else is wrong — likely a build script that modifies source files in place, or a symlink that confuses the watcher.

147. The registry version must match what the solver sees

Your workspace's packageSet.registry field specifies a snapshot of the PureScript registry. Only packages published at or before that snapshot are visible to the resolver.

workspace:
  packageSet:
    registry: 63.2.0  # Packages published after this snapshot are invisible.

A package that appears on Pursuit, that you can browse and read the documentation for, may nonetheless fail to resolve if it was published after your registry snapshot. The error — typically "package not found" or "no version satisfying constraint" — gives no hint that the issue is temporal.

The fix is either to bump the registry version to a snapshot that includes the package, or to add the package to extraPackages with an explicit source. When in doubt, check the package's publish date against your registry version.

148. Use spago ls packages and spago ls deps to debug resolution

When a package is "not found" and you are not sure why, two commands answer most questions:

# What packages does my current package set contain?
spago ls packages

# What does my resolved dependency tree look like?
spago ls deps

spago ls packages shows every package visible in your current package set, with its version. If the package you want is not listed, it is not in your snapshot — see entry 73. spago ls deps shows the resolved dependency tree for your project, including transitive dependencies. If a package appears in ls packages but not in ls deps, you have not added it to your dependencies list.

These commands are faster and more reliable than reading the Spago source, guessing at resolution logic, or asking in Discord. Use them before you debug.

XIV. Power tools — when to wield, when to sheathe

PureScript offers type-level programming, extensible effects, and generic programming over row types. These are genuine capabilities, not parlour tricks. But they carry costs in compile time, error message clarity, and code legibility. Reach for them when simpler tools fall short, not before.

149. Do not reproduce a lax type system with powerful tools

Do not be seduced into reproducing a more lax type system with PureScript's powerful tools. Variant, RowList, and heterogeneous record machinery are genuine capabilities, but the temptation is to reach for them to build the kind of loose, dynamic-feeling structures familiar from less precise languages — open unions where a closed sum type would do, generic record traversals where three concrete functions would suffice.

-- If your sum type has four constructors, this is the right tool:
data Output = Clicked | Hovered | Selected String | Dismissed

-- This is not an improvement:
type Output = Variant
  ( clicked :: Unit
  , hovered :: Unit
  , selected :: String
  , dismissed :: Unit
  )

When Variant and heterogeneous types earn their keep. These tools have legitimate uses — the key is that they solve extensibility problems that closed types cannot.

Effect rows in Run are the clearest example. Each module contributes its own effects, and the application composes them without a central sum type that every module must import:

-- Each module defines its own effect; the application never enumerates them all.
type AppEffects = (db :: DB, log :: LOG, auth :: AUTH)

app :: forall r. Run (AppEffects + r) Unit

Extensible component outputs are another. A reusable component that emits outputs should let its parent extend the output row without forking the component:

-- The parent can add its own cases without modifying the child.
type ChildOutput r = Variant (saved :: Entity, deleted :: EntityId | r)

In both cases, the defining characteristic is that the set of cases is genuinely open — downstream code must add its own cases without modifying the original type.

The costs are real. Type errors involving RowList constraints routinely span twenty lines. Compile times grow. Editor tooling struggles. The code resists casual modification. Start with a sum type. Move to Variant when you hit a concrete extensibility requirement that the sum type cannot satisfy. Reach for RowList traversal when the set of fields is genuinely unknown at definition time — not as a first resort.

150. Use type-level code for what only type-level code can do

Type-level programming in PureScript — Symbol, RowList, type-class-level computation with functional dependencies — is a real capability, not a party trick. Libraries like simple-json and routing-duplex use it to derive codecs and parsers from types alone, eliminating entire categories of boilerplate.

But type-level code has real costs. Compile times increase, sometimes dramatically. Error messages become walls of unsolved constraint text that even experienced programmers must squint at. And the code is legible only to the subset of PureScript programmers who have internalized the type-level idioms — a set that, in a small community, may be a set of one.

-- Type-level: generic JSON codec derived by walking the RowList.
-- Powerful, but error messages are walls of unsolved constraints.
encodeRecord :: forall r rl
   . RowToList r rl
  => EncodeJsonRL rl r
  => Record r -> Json

-- Value-level: explicit codec, readable errors, sufficient for one type.
encodeUser :: User -> Json
encodeUser u = encodeJson { name: u.name, email: u.email }

The first version eliminates boilerplate across many record types — it earns its keep in a library like simple-json. The second is more readable, produces better errors, and is sufficient when you control the types. Note that row polymorphism (forall r. { port :: Int | r } -> Effect Unit) is not what this entry is about — open records are basic PureScript and should be used freely. The warning applies to RowList iteration, Symbol manipulation, type-class-level computation with functional dependencies, and Proxy-heavy APIs.

Reserve type-level machinery for guarantees that must hold at compile time and cannot be expressed any other way. If a plain function over a sum type solves the problem, it solves the problem.

151. Match the abstraction to the problem (Run, free monads, extensible effects)

Run is an extensible effects system built on free monads over variant rows. It lets you define effects as data types, compose them as row-polymorphic unions, and swap interpreters without changing business logic. If you have read the literature on algebraic effects or used Eff in Haskell's freer-simple or polysemy, the idea is familiar.

For a large application with many interchangeable effect interpreters — say, a production interpreter that hits a database and a test interpreter that uses an in-memory map — Run is a legitimate architectural choice. The effect rows document exactly which capabilities a function requires, and the interpreters are first-class values you can compose and test independently.

For a Halogen app with two or three effects — reading config, making HTTP requests, logging — ReaderT Config Aff is simpler, faster, and understood by every PureScript programmer who has read the Halogen guide. The overhead of defining effect types, writing interpreters, and resolving the row-polymorphic constraints is not justified by the flexibility you gain when there is only one interpreter you will ever use.

-- For most Halogen apps, this is enough:
newtype AppM a = AppM (ReaderT Env Aff a)

-- Run earns its keep when you need this:
type AppEffects = (db :: DB, log :: LOG, auth :: AUTH, cache :: CACHE)

app :: forall r. Run (AppEffects + r) Unit

Match the abstraction to the problem. If you are not swapping interpreters, you are paying for extensibility you do not use.

152. Never use unsafeCoerce to hide types; use the CPS existential pattern

When you need to store values of different types in a collection, or pass a value whose concrete type the consumer need not know, the temptation is to reach for unsafeCoerce or Foreign to erase the type and cast it back later. This is unsafe in the precise sense that the compiler cannot check it — a refactor that changes the hidden type will compile successfully and crash at runtime.

The safe alternative is the continuation-passing style (CPS) existential encoding described in entry 118. To recap the pattern briefly:

-- Hide a concrete type behind a constraint.
newtype SomeShowable = SomeShowable (forall r. (forall a. Show a => a -> r) -> r)

mkSomeShowable :: forall a. Show a => a -> SomeShowable
mkSomeShowable a = SomeShowable \k -> k a

-- Use it: the consumer never learns the concrete type.
showHidden :: SomeShowable -> String
showHidden (SomeShowable run) = run show

The consumer provides a function that works for any type satisfying Show, and the hidden value is applied to it. No casts, no runtime tags, no possibility of mismatch.

The alternative — unsafeCoerce-ing to Foreign and casting back — is the kind of code that works until someone changes the hidden type. The CPS encoding makes that same change a compile error, which is where you want to discover it.

If the rank-2 types feel unfamiliar, invest the time to understand entry 118's explanation. The pattern appears throughout the ecosystem, and it is the idiomatic way to express existentials in PureScript.

153. Existentials are an anti-pattern unless you have measured a performance need

CPS-encoded existential types in PureScript allow you to hide a type parameter behind a universal quantifier. They are occasionally necessary and almost always the wrong tool.

The canonical alternative is a closure. Instead of packaging a value with its operations into an existential wrapper, close over the value and expose only the operations.

-- Closure: simple, direct.
type Renderer = { render :: Effect Unit, resize :: Int -> Int -> Effect Unit }

mkCanvasRenderer :: Canvas -> Renderer
mkCanvasRenderer canvas =
  { render: drawCanvas canvas
  , resize: \w h -> resizeCanvas canvas w h
  }
-- Existential: pays a complexity tax for no benefit here.
data Renderer = forall s. Renderer
  { state :: s
  , render :: s -> Effect Unit
  , resize :: s -> Int -> Int -> Effect Unit
  }

Existential types earned their place in Halogen's internal architecture, where they produced roughly 40% memory reduction when processing millions of virtual DOM nodes. That is an exceptional case in a framework's hot path, measured and justified. For application code, the closure version is simpler, carries no encoding overhead, and composes naturally with the rest of PureScript. (Nate Faubion)

Do not optimise for a problem you have not measured. Existentials are a power tool; most joins need wood glue.

154. Monomorphise hot paths

Polymorphic functions in PureScript are compiled to JavaScript functions that receive type class dictionaries as extra arguments. At each call site, the compiler passes the appropriate dictionary. This is the mechanism behind ad-hoc polymorphism, and for most code the overhead is negligible.

In tight loops or performance-critical sections, the dictionary lookup and indirect call can matter. If profiling reveals a polymorphic function as a bottleneck, write a monomorphic wrapper:

-- Polymorphic:
sumWith :: forall a. Semiring a => Array a -> a
sumWith = foldl add zero

-- Monomorphic, for a hot path:
sumNumbers :: Array Number -> Number
sumNumbers = foldl add zero

The monomorphic version allows the compiler (and the JavaScript engine's JIT) to eliminate the dictionary indirection. But profile first. The overwhelming majority of PureScript code is not in a hot loop, and premature monomorphisation sacrifices generality for speed you may not need.

This is the complement to entry 64 on writing functions over Foldable (section VI). Both are right — generalise by default, specialise where profiling tells you to. The generic form is for API design; monomorphisation is for inner loops.

XV. Halogen patterns

Halogen is PureScript's most widely used UI framework. These entries cover patterns specific to Halogen's component model — not general PureScript style, but idioms that make Halogen code cleaner and more maintainable.

155. Prefer render functions over components

Not every piece of reusable HTML needs to be a Halogen component. A component carries overhead: a State type, an Action type, an initialState, a handleAction, lifecycle management, and a slot type at every use site. If the piece in question has no independent state and raises no actions, all of that machinery is waste.

A plain render function is simpler:

-- A render function: no state, no lifecycle, no slot type
statusBadge :: forall w i. Status -> HH.HTML w i
statusBadge = case _ of
  Active   -> HH.span [ HP.class_ (ClassName "badge-active") ] [ HH.text "Active" ]
  Inactive -> HH.span [ HP.class_ (ClassName "badge-inactive") ] [ HH.text "Inactive" ]

Use a component when you need internal state, subscriptions, or effects in response to user interaction. Use a render function — a value or function returning HTML — when you are simply translating data into markup. The distinction is not about size; it is about whether the thing has behaviour of its own.

156. Store minimal canonical state; derive the rest in render

If a value can be computed from other state, compute it in render. Do not store it alongside the data it depends on.

-- Derived state stored explicitly: can go stale
type State =
  { items :: Array Item
  , selectedItems :: Array Item   -- derived from items + selection
  , totalPrice :: Number          -- derived from selectedItems
  }

-- Minimal canonical state: nothing to synchronise
type State =
  { items :: Array Item
  , selectedIds :: Set ItemId
  }

render :: State -> HTML
render state =
  let
    selectedItems = filter (\i -> Set.member i.id state.selectedIds) state.items
    totalPrice = foldl (\acc i -> acc + i.price) 0.0 selectedItems
  in
    ...

Every piece of derived state is a synchronisation obligation. When you update items, you must remember to update selectedItems and totalPrice. Forget one, and the UI shows stale data with no compiler warning.

The canonical state is the smallest set of values from which everything else can be recomputed. Store that, and let render do the rest.

157. Model component actions as what happened, not what to do

Name actions after events, not effects. An action is a record of something that occurred; the handler decides what it means.

Prefer:

data Action
  = SearchTermChanged String
  | ResultClicked ResultId
  | FilterToggled FilterType
  | PageLoaded

Over:

data Action
  = UpdateSearchResults String
  | NavigateToResult ResultId
  | SetFilterAndRefresh FilterType
  | FetchInitialData

The first set describes what the user did. The second prescribes what the system should do, embedding implementation decisions in the type. When requirements change — perhaps FilterToggled should now also log an analytics event — the event-style action accommodates the change in the handler without renaming the action. The imperative-style action, SetFilterAndRefresh, must either be renamed (breaking every reference) or become a lie.

Actions named after events also compose better with parent-child communication. A parent receiving ResultClicked can decide independently what that means. A parent receiving NavigateToResult has already been told what to do.

158. Use the ReaderT pattern for non-trivial Halogen apps

As applications grow, components need access to shared resources: API clients, configuration, authentication state. Threading these as props through every layer of the component tree does not scale.

The ReaderT pattern, demonstrated extensively in Real World Halogen, provides a principled alternative. Your application monad carries an environment, and any component can read from it.

newtype AppM a = AppM (ReaderT Env Aff a)

derive newtype instance Functor AppM
derive newtype instance Apply AppM
derive newtype instance Applicative AppM
derive newtype instance Bind AppM
derive newtype instance Monad AppM
derive newtype instance MonadEffect AppM
derive newtype instance MonadAff AppM

instance MonadAsk Env AppM where
  ask = AppM ask

For global mutable state — the current user, a notification queue — prefer halogen-store over rolling your own ReaderT with a Ref. The library handles subscription, notification of changes, and cleanup. Reserve manual ReaderT + Ref for cases where you need fine-grained control over when subscribers are notified. (Thomas Honeyman)

See also entry 92 on newtypes for transformer stacks, and entry 165 on the capability pattern that builds on this foundation.

XVI. Practical tasks

Concrete guidance for common programming tasks. Each entry recommends a specific library or approach for a specific problem, chosen for reliability and ecosystem fit.

159. Generating random values: use Effect.Random, not unsafePerformEffect

Random number generation is an effect. It reads from a source of entropy, which is external state by definition. Wrapping it in unsafePerformEffect to get a "pure" random value is not pure — it is a lie that the compiler cannot detect but your program's behaviour will eventually reveal.

-- Wrong: pretending randomness is pure.
randomColor :: String
randomColor = unsafePerformEffect do
  i <- randomInt 0 5
  pure (colors !! i)

-- Right: acknowledging the effect.
randomColor :: Effect String
randomColor = do
  i <- randomInt 0 5
  pure (fromMaybe "#000" (colors !! i))

Use Effect.Random for one-off random values in effectful code. Use MonadGen and Gen for property-testing generators, where you need controlled, reproducible randomness from a seed. If you need deterministic "randomness" for a pure function — procedural generation, shuffling with a known seed — pass the seed explicitly and use a pure PRNG. The type signature should always tell the truth about where the entropy comes from.

160. CLI argument parsing: use optparse, not hand-rolled case matching on argv

purescript-optparse gives you typed argument parsing with help text generation, subcommands, default values, and validation — all derived from a declarative description of your interface.

-- Structured: self-documenting, validated, composable.
opts :: Parser Options
opts = ado
  input <- strOption (long "input" <> metavar "PATH" <> help "Input file")
  verbose <- switch (long "verbose" <> help "Enable verbose output")
  in { input, verbose }

-- Hand-rolled: fragile, undocumented, silently wrong.
main :: Effect Unit
main = do
  args <- Process.argv
  case args !! 2, args !! 3 of
    Just "--input", Just path -> run path
    _, _ -> log "Usage: mytool --input <path>"

Manually indexing into process.argv produces code that is fragile when arguments are reordered, undocumented when someone passes --help, and silently wrong when optional arguments shift the indices. A declarative parser describes the interface once and derives both the parsing logic and the usage message from that single description.

Even for tools with only one or two arguments, the structured approach costs less than the debugging session you will eventually have when someone passes the arguments in the wrong order.

161. Date and time: use the types, not epoch integers

Passing Int or Number for timestamps invites an entire category of arithmetic errors: milliseconds versus seconds, timezone-unaware subtraction, comparing instants with durations.

-- Opaque integers: what unit? what timezone? who knows.
isExpired :: Int -> Int -> Boolean
isExpired expiresAt now = now > expiresAt

-- Typed: the units and semantics are in the types.
isExpired :: Instant -> Instant -> Boolean
isExpired expiresAt now = now > expiresAt

timeUntilExpiry :: Instant -> Instant -> Duration
timeUntilExpiry expiresAt now = diff expiresAt now

Use DateTime for calendar dates and times, Instant for points on the UTC timeline, and Duration or Milliseconds for differences between them. The date and time libraries (purescript-datetime, purescript-now) provide these types with the arithmetic you need. The compiler will prevent you from adding an Instant to an Instant or subtracting a Duration from a Date — errors that are trivially easy with bare integers and surprisingly common in production code.

Convert to and from epoch integers at the boundary: when reading from a database, an API response, or a JavaScript interop call. Inside your program, let the types carry the meaning.

162. Regular expressions: compile once, use many

Regex.regex returns Either String Regex because the pattern string might be syntactically invalid. This is a check that needs to happen once, not on every use.

-- Right: compile once, reuse.
numberPattern :: Regex
numberPattern = unsafePartial $ fromRight $ regex "\\d+" noFlags

extractNumbers :: Array String -> Array (Maybe (NonEmptyArray Match))
extractNumbers = map (match numberPattern)

-- Wrong: recompiling inside a map.
extractNumbers :: Array String -> Array (Maybe (NonEmptyArray Match))
extractNumbers = map \s ->
  case regex "\\d+" noFlags of
    Left _  -> Nothing
    Right r -> match r s

If the pattern is a literal known at compile time, unsafePartial $ fromRight is justified — you are asserting that the string is a valid regex, and if it is not, the crash at startup is the correct behaviour. For patterns constructed from user input, handle the Left case properly and compile once at the point of input, passing the compiled Regex value to everything downstream.

Compiling a regex is not expensive in absolute terms, but doing it inside a tight loop is the kind of unnecessary work that accumulates quietly until profiling reveals it.

163. HTTP requests: decode the response, do not assume its shape

An HTTP response body is a String or an ArrayBuffer. It is not your domain type. The gap between the wire format and your types is where every integration bug lives, and a codec is the firewall.

-- Wishful thinking: assuming the response matches.
fetchUser :: Int -> Aff User
fetchUser id = do
  response <- get json ("/api/users/" <> show id)
  pure response.body  -- What if the shape changed?

-- Defensive: decode explicitly, handle failure.
fetchUser :: Int -> Aff (Either JsonDecodeError User)
fetchUser id = do
  response <- get string ("/api/users/" <> show id)
  pure $ decodeUser response.body

The server will eventually change its response format — a field will be renamed, a nullable field will appear, an envelope will be added. Your decoder is the single place where that change surfaces as an error rather than propagating silently through your application as a wrong value in the right type.

Write the decoder alongside the request function. Test it against example responses. When the API changes, the decoder fails loudly and locally, not quietly and everywhere.

A pragmatic exception: if you are on a JavaScript backend and receiving very large JSON payloads (megabytes of data), the overhead of a full decode-and-reconstruct pass may matter. In that narrow case, treating the parsed JSON as a trusted JavaScript object — skipping the PureScript codec — is a defensible performance trade-off. Document it explicitly, confine it to one module, and accept the runtime risk. This is a production performance decision, not a default.

164. File I/O in Node: use the Aff wrappers, not raw FFI

purescript-node-fs-aff wraps Node's fs module with Aff-based functions that handle callbacks, errors, and cancellation correctly. Using the callback-based FFI directly means reimplementing all of this by hand.

-- Raw FFI: you own the callback, the error handling, and the cancellation.
foreign import readFileImpl :: String -> (String -> Effect Unit) -> (Error -> Effect Unit) -> Effect Unit

-- Aff wrapper: all of that is handled.
import Node.FS.Aff (readTextFile)

contents <- readTextFile UTF8 "/path/to/file"

Aff gives you structured error handling with try and catchError, automatic resource cleanup with bracket, and cancellation propagation through forkAff. Writing raw callback-based FFI means giving up all three and rebuilding them ad hoc, or more likely, not rebuilding them and discovering the gap in production.

The same principle applies to any Node API that uses callbacks. If an Aff wrapper exists in the ecosystem, use it. If one does not, write a small wrapper using makeAff and contain the callback machinery in one place.

165. Logging: use structured data, not string concatenation

String-concatenated log messages are easy to write and hard to use. They cannot be filtered, queried, or parsed reliably. They embed formatting decisions at every call site, making global changes to log format impossible.

-- Unstructured: ungreppable, inconsistent, fragile.
log ("User " <> show userId <> " logged in at " <> show timestamp <> " from " <> ipAddress)

-- Structured: a record you can format, filter, and forward.
type LogEntry =
  { level     :: LogLevel
  , event     :: String
  , userId    :: UserId
  , timestamp :: Instant
  , metadata  :: Map String String
  }

logEvent { level: Info, event: "login", userId, timestamp, metadata: Map.singleton "ip" ipAddress }

At minimum, define a LogEntry record and a single formatting function. Better, use a logging library that accepts structured data and routes it to the appropriate sink. The point is not sophistication — it is that the log format is defined in one place, not scattered across every log call in the codebase.

Structured logging also makes the logging surface greppable in the source code. logEvent with a known record type is easy to find; log ("User " <> ...) in forty variations is not.

166. Environment variables: read at startup, not on demand

Do not sprinkle lookupEnv calls throughout your codebase. Each one is an implicit dependency on external state — invisible in the type signature, untestable without modifying the environment, and discovered only at the moment of execution.

-- Scattered: each module reads what it needs, when it needs it.
connectDb :: Aff Connection
connectDb = do
  host <- liftEffect $ lookupEnv "DB_HOST"
  port <- liftEffect $ lookupEnv "DB_PORT"
  ...

-- Gathered: read once, validate, pass explicitly.
type Config =
  { dbHost :: String
  , dbPort :: Int
  , logLevel :: LogLevel
  }

readConfig :: Effect (Either String Config)
readConfig = do
  dbHost <- lookupEnv "DB_HOST"
  dbPort <- lookupEnv "DB_PORT"
  ...

main :: Effect Unit
main = do
  config <- readConfig >>= either die pure
  runApp config app

Read all environment variables in main. Validate them — a missing DB_HOST should be a startup error, not a runtime surprise ten minutes later. Construct a Config record and pass it through ReaderT or as an explicit argument. This makes the configuration surface visible in one place, testable by constructing a Config value directly, and mockable without touching the process environment.

XVII. Naming and style

Conventions that have no deep justification beyond consistency. Their value is that the community follows them; deviating without reason creates friction for readers who expect the standard patterns.

167. Order imports: Prelude, then libraries, then local modules

Three groups, separated by blank lines, alphabetical within each group.

import Prelude

import Data.Array (filter, length)
import Data.Map as Map
import Data.Maybe (Maybe(..))
import Effect.Aff (Aff)

import MyApp.Data.User (User)
import MyApp.Util (formatDate)

The reader can tell at a glance what comes from the language's Prelude, what comes from the ecosystem, and what is project-local. When a module adds a new dependency, the diff touches only the relevant group. When reviewing unfamiliar code, the import section is a table of contents — keep it organised.

Note: purs-tidy does not rearrange imports — it formats code structure but leaves import ordering to you. However, the PureScript language server provides source.organizeImports and source.sortImports code actions, which VS Code can run on save via editor.codeActionsOnSave. This removes unused imports and sorts alphabetically within each group — a useful complement to the grouping convention above.

168. Qualify container imports

import Data.Map as Map and write Map.lookup, Map.insert, Map.empty at every call site. The same for Set, List, StrMap, and any container whose operations have generic names.

-- Ambiguous: which lookup? which empty?
import Data.Map (lookup, insert, empty)
import Data.Set (empty, insert)  -- name clash

-- Unambiguous: the container is visible at the point of use.
import Data.Map as Map
import Data.Set as Set

result = Map.lookup key (Map.insert key value Map.empty)
items  = Set.insert item Set.empty

lookup, insert, empty, singleton, and fromFoldable appear in half a dozen modules. Qualifying them avoids name clashes and makes the container type visible without tracing back to the import list. The three extra characters per call site are an investment in readability that compounds over the life of the codebase.

169. Document every export with a doc comment

PureScript doc comments (-- |) appear in generated documentation and in IDE hover popups. Every exported function and type should have one.

-- | Partition nodes into layers by their depth from the root.
-- | Nodes unreachable from the root are placed in a separate overflow layer.
layerByDepth :: Graph -> Array (Array Node)

The comment describes what the function does and any non-obvious behaviour (the overflow layer). It does not describe the implementation. If a function is not worth documenting, it is probably not worth exporting.

For internal helpers, a brief comment is still welcome but not obligatory — the type signature and a well-chosen name often suffice. For exports, the doc comment is part of the API contract. Write it as if the reader cannot see the source code, because often they cannot.

170. Documentation describes what, not how

"Returns the first element, or Nothing if empty." Not: "Pattern matches on the array, checks if the length is zero, then returns the head."

-- | Compute the bounding box that encloses all points.
-- | Returns Nothing if the array is empty.
boundingBox :: Array Point -> Maybe BoundingBox

-- Not:
-- | Folds over the array, tracking the min and max x and y
-- | coordinates, then constructs a BoundingBox from the extremes.

If you feel the need to explain the mechanism, that is often a signal that the function is doing too much or that its name does not convey its purpose. A doc comment that restates the implementation in English is pure noise — the reader could have read the code. A doc comment that states the contract gives the reader something the code alone does not: permission to stop reading.

171. Use mixed case for abbreviations: HttpServer, not HTTPServer

When an abbreviation appears in a CamelCase identifier, treat it as a word: HttpServer, JsonParser, XmlNode. The exception is two-letter abbreviations, which remain uppercase: IO, Id.

The reason is legibility at word boundaries. HTTPSConnection forces the reader to determine where HTTPS ends and Connection begins. HttpsConnection is unambiguous. HTMLParser could be HT + MLParser if you squint; HtmlParser cannot be misread.

This convention follows the PureScript ecosystem's prevailing practice and aligns with the Haskell style guides (Tibbe, Kowainik). Consistency within a codebase matters more than the specific choice, but if you are starting fresh, mixed case is the safer default.

172. Use singular module names

Data.Map, not Data.Maps. MyApp.Route, not MyApp.Routes. Component.Sidebar, not Components.Sidebar.

A module represents a concept — the Map type and its operations, the Route type and its parser, the Sidebar component — not a collection of instances of that concept. The singular name is both more precise and more consistent with the PureScript and Haskell ecosystem conventions.

The plural form tempts when a module contains "many things" — many routes, many components. But the module itself is still one thing: the namespace for those definitions. Name it for what it is, not what it contains.

173. Do not mix let and where in the same definition

A definition that scatters bindings between let (above the main expression) and where (below it) forces the reader to look in two places to understand the function's vocabulary.

-- Mixed: where is `margin` defined? Where is `scaled`?
render state =
  let scaled = state.value * factor
  in svg [ viewBox 0.0 0.0 width height ]
       [ rect [ x margin, y margin, width (width - 2.0 * margin), height scaled ] ]
  where
  factor = 2.5
  margin = 10.0

Pick one style per definition. Use where for named helpers that support the main expression. Use let for intermediate values that feed into the next step. Do not split the supporting cast between two stages.

174. Name recursive helpers go or loop

When a function uses an inner recursive helper with an accumulator, the conventional name is go (or sometimes loop). This is not a PureScript invention — it is established practice across Haskell, Scala, and the broader FP community.

findIndex :: forall a. (a -> Boolean) -> Array a -> Maybe Int
findIndex pred arr = go 0
  where
  go i
    | i >= Array.length arr = Nothing
    | pred (unsafePartial $ Array.unsafeIndex arr i) = Just i
    | otherwise = go (i + 1)

The name go signals "this is the tail-recursive workhorse; the outer function is the public interface." Any FP programmer recognises the pattern instantly. A descriptive name like findFrom is also fine, but avoid inventing a new naming convention for each function — consistency across the codebase is more valuable than local precision.

175. Do not shadow; the compiler warns for a reason

Shadowing — binding a new value with the same name as an existing binding in scope — is legal PureScript. The compiler warns about it. Heed the warning.

-- The second `result` shadows the first.
do
  result <- fetchUser id
  let result = formatUser result  -- Warning: shadowed binding
  log result                      -- Which result? The formatted one.

In short functions, shadowing is merely confusing. In long do blocks — the kind that appear in Halogen handleAction functions — it is a reliable source of bugs. The old binding is still in scope but unreachable by name. A later refactor that reorders lines may silently change which result is referenced.

The fix is usually a better name: formatted, userStr, or whatever describes the new value's role. If you cannot think of a distinct name, that is often a sign the two values should not coexist in the same scope.

176. Keep warnings under control

The PureScript compiler's warnings are precise: unused imports, missing type signatures, shadowed names, redundant patterns, incomplete binds. Most identify code that is wrong, dead, or unclear.

In spago.yaml, you can enforce this:

package:
  build:
    censor_warnings:
      - WildcardInferredType
    strict: true

A codebase with a dozen warnings trains its authors to ignore the thirteenth — which might be the one that matters. The goal is that every warning is either fixed or consciously suppressed with a reason.

That said, "zero warnings always" is a guideline, not a law. Shadowed name warnings, for instance, are sometimes a sign of clear code — a let rebinding a function parameter with the same name after validation is arguably more readable than inventing a new name. The censor_warnings mechanism exists precisely so you can make deliberate, documented decisions about which warnings matter in your codebase. The sin is not having warnings; it is having warnings nobody looks at.

XVIII. Testing

PureScript's type system catches many bugs at compile time, but not all. Property-based testing and law checking fill the gap, especially for the algebraic structures that type classes encode.

177. Write property-based tests, not just examples

An example test says "this input produces this output." A property test says "for all inputs satisfying these constraints, this relationship holds." The second finds bugs the first never will, because it explores inputs the author did not think to try.

-- Property: tests the relationship for all generated users
quickCheck \(user :: User) ->
  decode userCodec (encode userCodec user) === Right user

-- Example: tests one case
it "decodes what it encodes" do
  let user = { name: "Alice", role: Admin }
  decode userCodec (encode userCodec user) `shouldEqual` Right user

The property version requires an Arbitrary User instance, which forces you to think about the space of valid inputs — itself a useful exercise. It then generates hundreds of random users, including edge cases (empty strings, boundary values, unusual characters) that a hand-written example would never include.

Good candidates for property tests: codec roundtrips, monoid laws (mempty <> x === x), idempotency (f (f x) === f x), commutativity, and ordering consistency. These are universal relationships, and testing them universally is what property-based testing is for.

178. Test laws with purescript-quickcheck-laws

If your type has instances for Eq, Ord, Semigroup, Monoid, Functor, or Monad, those instances carry algebraic laws. Eq must be reflexive, symmetric, and transitive. Semigroup's append must be associative. Monad must satisfy left identity, right identity, and associativity.

An instance that violates its laws is worse than no instance at all. Code that uses your type through the class interface assumes the laws hold. When they do not, the bugs are subtle, non-local, and maddening to track down.

import Test.QuickCheck.Laws.Data.Eq (checkEq)
import Test.QuickCheck.Laws.Data.Ord (checkOrd)
import Test.QuickCheck.Laws.Data.Monoid (checkMonoid)

main :: Effect Unit
main = do
  checkEq (Proxy :: Proxy MyType)
  checkOrd (Proxy :: Proxy MyType)
  checkMonoid (Proxy :: Proxy MyType)

purescript-quickcheck-laws generates random instances of your type and verifies each law with property-based tests. This requires an Arbitrary instance, which is itself a useful exercise — if you cannot generate random values of your type, your type may be too constrained to test effectively.

Write these tests when you define the instances, not after a bug report. (JamieBallingall, Gary Burgess)

De Gustibus

These are matters where reasonable PureScript programmers differ. We present the cases without ruling.

where or let

Some prefer where for named helpers, reserving let for intermediate values within do blocks. The argument: where puts the main expression first, so the function reads top-down — topic sentence, then supporting detail.

handleAction = case _ of
  Initialize -> generateLayout
  SetGridSize size -> do
    H.modify_ _ { gridSize = size }
    generateLayout
  where
  generateLayout = do
    state <- H.get
    let cells = waffle state.gridSize state.gridSize vp sampleCounts
    H.modify_ _ { cells = cells }

  vp = viewport 400.0 400.0

Others prefer let throughout, on the grounds that definitions should appear before their use — as they do in most languages — and that where scattering definitions below the expression makes them harder to locate in a long module.

handleAction action = do
  let vp = viewport 400.0 400.0
  let generateLayout = do
        state <- H.get
        let cells = waffle state.gridSize state.gridSize vp sampleCounts
        H.modify_ _ { cells = cells }
  case action of
    Initialize -> generateLayout
    SetGridSize size -> do
      H.modify_ _ { gridSize = size }
      generateLayout

Both positions have merit. The compiler does not prefer one to the other.

Point-free or pointed

Point-free style — defining functions without naming their arguments — can clarify or obscure depending on context.

When the pipeline is a clean chain of transformations, point-free is natural:

normalise :: String -> String
normalise = toLower >>> trim >>> replaceAll (Pattern "  ") (Replacement " ")

When the logic involves branching or multiple uses of the same argument, naming it is clearer:

classify :: Score -> Rating
classify score
  | unwrap score >= 90 = Excellent
  | unwrap score >= 70 = Good
  | otherwise = NeedsWork

The forced point-free version of the second would require gymnastics that help no one. Conversely, writing normalise s = replaceAll ... (trim (toLower s)) nests where it could flow.

The test: if removing the argument name makes the function harder to read aloud, keep the name.

Natural transformations: ~> or explicit forall

The natural transformation operator ~> provides terse syntax for functions that are polymorphic in their argument's type parameter.

The case for ~>:

interpret :: MyFreeF ~> Aff

One line, immediately recognisable to anyone who has worked with free monads or Halogen. The notation comes from category theory and carries the right connotation: a structure-preserving map between functors. For simple signatures, it is difficult to beat.

The case for explicit forall:

interpret :: forall a. MyFreeF a -> Aff a

~> has surprising precedence relative to ->, does not chain well in multi-argument signatures, and formats poorly when the types are complex. Nate Faubion: "It's the kind of thing that works ok in a very simple case, but scales poorly, and gets confusing quickly." The explicit version is always unambiguous, always composes with other type operators, and never surprises the reader who is less familiar with the notation.

In practice, ~> appears most often in Halogen component signatures and free monad interpreters. Outside those contexts, the explicit form is more common.

Parentheses, $, or composition

Three ways to apply f to the result of g x:

-- Parentheses
f (g (h x))

-- Dollar sign
f $ g $ h x

-- Composition
(f <<< g <<< h) x

Parentheses give the clearest error messages, especially for beginners — the compiler points to the exact subexpression. They require no knowledge of operator precedence. They are also visually noisy when nested more than two deep.

$ eliminates trailing parentheses and is the most common idiom in practice. f $ g $ h x reads as "apply f to the result of applying g to the result of h x." Two or three $ signs are comfortable; a chain of six suggests the expression wants to be broken into named subexpressions.

Composition (<<< or >>>) is a third option that eliminates the argument entirely when you are building a pipeline. It is most natural when the result will be passed to map or stored as a value.

None of these is canonical. Pick what reads best at the point of use, and do not mix all three styles in a single expression.

Left-to-right (#/>>>) or right-to-left ($/<<<)

PureScript offers operators in both directions:

-- Right-to-left: traditional function application order.
result = render $ transform $ parse input

-- Left-to-right: data-flow order.
result = input # parse >>> transform >>> render

Right-to-left ($, <<<) follows mathematical convention and is more common in the PureScript and Haskell ecosystems. The function that runs last appears first, which mirrors how you would read f(g(x)).

Left-to-right (#, >>>) follows the data. The reader traces the value from source to destination in reading order. This is natural for pipelines and method-chain-like transformations, and it is often preferred by programmers coming from languages with pipe operators (Elixir, F#, shell scripting).

The important thing is consistency within a codebase. Mixing $ and # in the same function — or worse, the same expression — creates a reader who must constantly switch mental models. Pick a direction and stay with it.

ADT formatting: leading pipe or not

Two styles for formatting sum type constructors:

-- Leading pipe (sometimes called "bar-led").
data Severity
  = Info
  | Warning
  | Error
  | Critical
-- First constructor unadorned.
data Severity = Info
              | Warning
              | Error
              | Critical

The leading-pipe style produces cleaner diffs — adding or removing a constructor changes exactly one line. It also aligns the constructors vertically, making it easy to scan. The community has largely converged on this style, and purs-tidy formats accordingly.

The unadorned-first style is more compact for two-constructor types (data Toggle = On | Off) and mirrors the BNF grammar notation that some find natural.

For types with more than two constructors, leading pipe has near-consensus, and purs-tidy formats accordingly. For single-line, two-constructor types, both are common and both are fine.

Shadowing: warn or allow

Entry 81 in the main text advises against shadowing — rebinding a name that is already in scope. The argument: shadowed names create confusion about which binding is in play, and the reader must track scope carefully.

The counterargument, from Gary Burgess among others: the shadowing warning "has caused me more trouble than it has saved." There are legitimate reasons to shadow a variable, particularly in do blocks where a value is transformed in stages:

-- Intentional shadowing: state is refined step by step.
do
  state <- getState
  let state = applyDefaults state
  let state = validateFields state
  saveState state

The shadowing communicates that the old value is superseded — you should not use the pre-defaults state after defaults have been applied. A non-shadowing version would require inventing names (state', state'', validatedState) that carry no additional meaning.

Others find that precisely this invention of names clarifies the transformation: rawState, defaultedState, validatedState tells the reader what each stage has accomplished.

Both positions reflect genuine experience. The compiler's -W shadow warning is optional for a reason.

Abbreviation casing: HttpServer or HTTPServer, Json or JSON

The general convention in PureScript follows the Haskell/Tibbe style: treat abbreviations longer than two letters as words, capitalising only the first letter.

-- Mixed case: abbreviations as words.
data HttpMethod = Get | Post | Put | Delete
type JsonCodec a = ...
newtype HtmlElement = ...
-- All-caps: abbreviation preserves its identity.
data HTTPMethod = GET | POST | PUT | DELETE
type JSONCodec a = ...
newtype HTMLElement = ...

The mixed-case argument: HttpsConnection is immediately parseable. HTTPSConnection requires the reader to decide where HTTPS ends and Connection begins. Camel case applies uniformly, and the abbreviation boundary is always clear.

The all-caps argument: JSON is a proper noun (JavaScript Object Notation), and rendering it as Json is like writing Nasa. The abbreviation has an accepted casing in every other context; PureScript should not override it. This argument was made forcefully in the argonaut-core naming debate.

Two-letter abbreviations (IO, UI, DB) are conventionally all-caps everywhere. Beyond two letters, the community is split, though mixed case is more common in the ecosystem's published packages.

Qualified imports or explicit imports

Two strategies for managing what comes into scope:

-- Qualified: everything under a prefix.
import Data.Map as Map
import Data.Set as Set

fn = Map.lookup key (Map.singleton "a" 1)
-- Explicit: name each import.
import Data.Map (Map, lookup, singleton)
import Data.Set (Set, member)

fn = lookup key (singleton "a" 1)

Qualified imports are noisier at call sites but self-documenting: Map.lookup tells the reader exactly where lookup comes from. They require no maintenance when you use a new function from the module. And they prevent name clashes without thought — Map.empty, Set.empty, and Array.empty coexist without conflict.

Explicit imports are concise at call sites and serve as documentation of what the module actually uses. They make unused imports visible (a linter can catch them). But they require maintenance — every time you use a new function, you must add it to the import list, and name clashes between modules require either qualification or renaming.

Many codebases use both: qualified imports for container-like modules (Map, Set, Array, String) and explicit imports for everything else. This is a reasonable middle ground, but it is a convention, not a rule.

Module splitting: aggressive or conservative

Some codebases put each type, each component, each function group in its own module. Others keep related definitions together in larger files.

The case for aggressive splitting: small modules are easy to navigate, easy to test in isolation, and produce clear dependency graphs. When a module has one responsibility, its imports and exports tell you everything about its relationships.

The case for conservative splitting: PureScript's orphan-instance rule requires that type class instances live in the module that defines either the class or the type. Splitting a type and its instances across modules is either impossible or requires restructuring. Aggressive splitting also produces deep import chains and can make it harder to understand a feature's full implementation.

A practical heuristic from entry 134: keep modules under roughly 400 lines. This is loose enough to accommodate types with their instances and tight enough to prevent modules from becoming grab-bags. Split when a module has two unrelated responsibilities, not when it reaches an arbitrary line count.

do notation or bind chains

do notation is overwhelmingly preferred in PureScript for monadic sequencing. But explicit >>= has its defenders for short pipelines.

-- do notation: the standard.
do
  user <- getUser uid
  posts <- getUserPosts user.id
  pure { user, posts }
-- Bind chain: emphasises the data flow.
getUser uid >>= \user ->
  getUserPosts user.id >>= \posts ->
    pure { user, posts }

do is more readable for sequences longer than two steps, for blocks that mix effectful and pure bindings, and for code that non-Haskell-background developers will read. >>= can be more natural for a single transformation — getUser uid >>= fetchProfile reads as a direct pipeline without the ceremony of do and <-.

In practice, >>= appears most often in point-free combinations (>>= traverse processItem) rather than in explicit lambda chains. The lambda-heavy bind chain above is harder to read than the do version and offers no compensating benefit.

Record updates: functional update or reconstruct

PureScript provides record update syntax for changing specific fields:

-- Functional update: change what differs.
newState = state { count = state.count + 1, loading = true }
-- Reconstruction: spell out every field.
newState =
  { count: state.count + 1
  , loading: true
  , user: state.user
  , items: state.items
  , error: state.error
  }

Functional update is concise, focuses the reader's attention on what changed, and is resilient to new fields — adding a field to the record type does not require updating every functional update site.

Reconstruction makes every field visible, which some find clearer when most fields are changing. It also avoids the subtlety that record update syntax uses = (update) rather than : (construction), which trips up newcomers.

For updates that change one or two fields out of many, functional update is the clear choice. For transformations that touch most fields, reconstruction can be clearer. The judgment is contextual.

Applicative do (ado) or liftN

For combining independent applicative computations, PureScript offers both ado notation and the liftN family:

-- ado: mirrors do notation, works for any number of fields.
mkUser :: F User
mkUser = ado
  name  <- validateName input.name
  email <- validateEmail input.email
  age   <- validateAge input.age
  in { name, email, age }
-- lift3: direct, concise for small arities.
mkUser :: F User
mkUser = lift3 (\name email age -> { name, email, age })
  (validateName input.name)
  (validateEmail input.email)
  (validateAge input.age)

ado scales to any number of fields, reads like do notation (which aids onboarding), and makes the binding names visible next to their sources. It is syntactic sugar over Apply, not Monad, so it works with Validation and other non-monadic applicatives.

liftN is more direct for two or three arguments and makes the applicative structure explicit. It does not scale past lift5 (and past lift3 it becomes hard to track which argument corresponds to which parameter). For binary combinations — lift2 Tuple a b, lift2 append x y — it is often more natural than an ado block.

Neither is wrong. ado is the more general tool; liftN is the sharper one for small cases.

Leading pipe in case expressions

Related to ADT formatting, the same question arises in case expressions:

-- Leading pipe: each branch starts with the same visual marker.
render = case _ of
  Loading -> HH.text "Loading..."
  Error msg -> HH.div_ [ HH.text msg ]
  Ready content -> viewContent content
-- This is the only style PureScript supports for case expressions.
-- Unlike Haskell, there is no layout-based alternative with leading pipes.

For case expressions, PureScript's syntax settles the question: each branch is an arrow (->), and the visual structure is determined by indentation. The "leading pipe" debate applies to data declarations and top-level pattern matches, not to case expressions. Where it does apply — in data declarations — see the ADT formatting entry above.