Enforcing Domain Boundaries in PureScript with Newtypes and Row Polymorphism
Learn how to use PureScript Newtypes and Row Polymorphism to eliminate primitive obsession and enforce strict data boundaries using the 'Parse, don't validate' pattern.
01 Feb 2026, 09:32 UTC

The Problem: Primitive Obsession and Data Leakage
In large-scale PureScript applications, it is common to represent IDs, emails, and currency as String or Int. This "primitive obsession" leads to bugs where a UserId is accidentally passed into a function expecting an OrderId, or an unvalidated raw string from an API request is treated as a trusted domain entity. Because these types are identical at the compiler level, the type system cannot prevent these logic errors.
The solution is to establish a strict trust boundary at the system edge, ensuring that data is parsed into refined types before it ever reaches the business logic.
The Smallest Suitable Design: Parse, Don't Validate
Instead of creating a validation function that returns a boolean (isValid :: String -> Boolean), use the "Parse, don't validate" pattern. This involves creating a domain type that can only be instantiated via a Smart Constructor—a function that returns an Either or Maybe.
Implementing Newtypes for Identity
A newtype in PureScript provides a type-level wrapper around an existing type with zero runtime overhead. It tells the compiler that while the underlying data is a string, the meaning is different.
-- Domain types
newtype UserId = UserId String
newtype OrderId = OrderId String
-- A function that specifically requires a UserId
fetchUser :: UserId -> Effect (Maybe User)
fetchUser (UserId id) = -- implementation
If you attempt to pass an OrderId to fetchUser, the code will fail to compile, preventing a class of runtime errors that are common in dynamically typed languages.
Establishing Trust Boundaries
To prevent the manual instantiation of these types with invalid data, keep the UserId constructor private to its module and expose only the smart constructor.
-- In Domain.Types module
module Domain.Types (UserId, mkUserId) where
newtype UserId = UserId String
-- The smart constructor
mkUserId :: String -> Either String UserId
mkUserId raw
| length raw >= 5 = Right (UserId raw)
| otherwise = Left "UserId must be at least 5 characters"
Managing Data Flexibility with Row Polymorphism
While newtypes protect individual values, Row Polymorphism protects the structure of your data records. It allows you to write functions that operate on a subset of fields without requiring the entire record to match a rigid type definition.
Consider a scenario where multiple entities have a createdAt timestamp. Instead of a complex inheritance hierarchy, use a row variable (r) to define a requirement for that specific field.
-- This function accepts any record that contains at least a 'createdAt' field of type Date
logCreationDate :: forall r. { createdAt :: Date | r } -> Effect Unit
logCreationDate record = do
log $ "Created at: " <> show (record.createdAt)
The { createdAt :: Date | r } syntax indicates that the function is polymorphic over the rest of the record (r). This maintains type safety while allowing the function to be reused across User, Order, and Product records.
Operational Checks and Failure Modes
Diagnostic Decision Table
| Scenario | Tool | Outcome |
|---|---|---|
| Mixing two different IDs | Newtype | Compile-time Type Mismatch |
| Invalid API input | Smart Constructor | Left ValidationError at the edge |
| Adding fields to a record | Row Polymorphism | Function remains compatible via r |
Failure Modes
- Verbose Wrapping: Excessive use of newtypes can lead to "wrapping fatigue," where code is littered with
(UserId id)patterns. Use these only for values that have distinct domain meanings. - Compiler Complexity: Deeply nested row polymorphism or highly complex row constraints can increase compile times and produce opaque error messages. Keep row constraints shallow.
- Explicit Mapping: Because PureScript does not support implicit conversions, every transition from a raw API record to a domain record requires an explicit mapping function. This is a deliberate trade-off for predictability.
Verification and Rollback
To verify the implementation, attempt to pass a raw String to a function expecting a UserId. The compiler should reject the code with a Couldn't match type String with UserId error.
Rollback: Since these changes primarily affect type definitions and function signatures, "rolling back" involves replacing newtype declarations with type aliases (e.g., type UserId = String). This removes the type-level protection but restores the ability to use raw strings throughout the application.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.