Programmers coming from object-oriented languages expect subtyping. If Dog extends Animal, a Dog value works anywhere an Animal is expected. PureScript has no such mechanism. Understanding why, and what it offers instead, clarifies how to structure data in functional programs.
Data Constructors Are Not Subtypes
Consider this algebraic data type:
data Shape
= Square { length :: Number }
| Rectangle { width :: Number, height :: Number }
| Circle { radius :: Number }Shape is a type. Square, Rectangle, and Circle are data constructors. They look like they might be subtypes, but they are not. They are functions:
Square :: { length :: Number } -> Shape
Rectangle :: { width :: Number, height :: Number } -> Shape
Circle :: { radius :: Number } -> ShapeEach constructor takes arguments and produces a Shape. There is no inheritance. A Square is not a kind of Shape in the OO sense; it is a Shape value built using the Square constructor. Pattern matching distinguishes which constructor was used:
area :: Shape -> Number
area (Square { length }) = length * length
area (Rectangle { width, height }) = width * height
area (Circle { radius }) = 3.14159 * radius * radiusThe Problem: Sharing Structure
In OO, subtyping lets you write functions that work on any type with a particular interface. Without subtyping, how do you write a function that operates on any record with a firstName field?
Consider these types:
type Employee =
{ firstName :: String
, lastName :: String
, title :: String
, department :: String
}
type Customer =
{ firstName :: String
, lastName :: String
, accountId :: String
}
type Contact =
{ firstName :: String
, lastName :: String
, email :: String
}All three have firstName and lastName. In an OO language, you might extract a Person base class. In PureScript, you use row polymorphism.
Row Polymorphism: Structural Sharing
A row-polymorphic function specifies the fields it needs while accepting records with additional fields:
formatName :: forall r. { firstName :: String, lastName :: String | r } -> String
formatName person = person.lastName <> ", " <> person.firstNameThe type forall r. { firstName :: String, lastName :: String | r } reads as: any record with at least firstName and lastName fields, plus whatever other fields r represents. The r is a row variable that captures the remaining fields.
This function works on Employee, Customer, and Contact:
emp :: Employee
emp = { firstName: "Ada", lastName: "Lovelace", title: "Engineer", department: "R&D" }
cust :: Customer
cust = { firstName: "Grace", lastName: "Hopper", accountId: "A-1234" }
-- Both work:
formatName emp -- "Lovelace, Ada"
formatName cust -- "Hopper, Grace"No inheritance. No base class. The function states what structure it requires, and any record satisfying that structure works.
Modifying Records with Row Polymorphism
Row polymorphism also handles record updates. A function that adds a prefix to a name:
data Prefix = Dr | Prof
addPrefix :: forall r. Prefix -> { firstName :: String | r } -> { firstName :: String | r }
addPrefix Dr rec = rec { firstName = "Dr. " <> rec.firstName }
addPrefix Prof rec = rec { firstName = "Prof. " <> rec.firstName }The return type preserves the row variable r. The function modifies firstName and returns a record with the same additional fields it received:
addPrefix Dr emp
-- { firstName: "Dr. Ada", lastName: "Lovelace", title: "Engineer", department: "R&D" }
addPrefix Prof cust
-- { firstName: "Prof. Grace", lastName: "Hopper", accountId: "A-1234" }The Employee stays an Employee. The Customer stays a Customer. Row polymorphism threads extra fields through without losing them.
Row Polymorphism vs Subtyping
Subtyping creates a hierarchy: Dog is-a Animal. The relationship is nominal (based on declared inheritance) and fixed at definition time.
Row polymorphism is structural: any record with the required fields satisfies the constraint. No hierarchy exists. Types don't know about each other. The relationship emerges at each use site based on structure.
| Aspect | Subtyping | Row Polymorphism |
|---|---|---|
| Relationship | Declared inheritance | Structural compatibility |
| Hierarchy | Fixed at definition | None |
| Extensibility | Requires modifying base class | Any matching record works |
| Coupling | Types know their parents | Types are independent |
When to Use Row Polymorphism
Row polymorphism fits when:
- Multiple record types share some fields
- Functions need access to specific fields but not the entire structure
- You want to avoid coupling types through a shared base
Row polymorphism does not replace:
- Sum types (use algebraic data types for variants)
- Behavioral polymorphism across unrelated types (use type classes)
- Nominal typing when you need to distinguish structurally identical types
Full Example
module RowPolymorphism where
import Prelude
type Employee =
{ firstName :: String
, lastName :: String
, title :: String
, department :: String
}
type Customer =
{ firstName :: String
, lastName :: String
, accountId :: String
}
-- Works on any record with firstName and lastName
formatName :: forall r. { firstName :: String, lastName :: String | r } -> String
formatName person = person.lastName <> ", " <> person.firstName
-- Works on any record with lastName, preserves other fields
addSuffix :: forall r. String -> { lastName :: String | r } -> { lastName :: String | r }
addSuffix suffix rec = rec { lastName = rec.lastName <> ", " <> suffix }
-- Works on any record with firstName, preserves other fields
addPrefix :: forall r. String -> { firstName :: String | r } -> { firstName :: String | r }
addPrefix prefix rec = rec { firstName = prefix <> " " <> rec.firstName }
-- Usage
emp :: Employee
emp = { firstName: "Ada", lastName: "Lovelace", title: "Engineer", department: "R&D" }
cust :: Customer
cust = { firstName: "Grace", lastName: "Hopper", accountId: "A-1234" }
-- All of these work:
-- formatName emp => "Lovelace, Ada"
-- formatName cust => "Hopper, Grace"
-- addSuffix "Jr" emp => { ..., lastName: "Lovelace, Jr", ... }
-- addPrefix "Dr." cust => { firstName: "Dr. Grace", ..., accountId: "A-1234" }PureScript's lack of subtyping is not a limitation. Row polymorphism provides structural sharing without inheritance, coupling, or type hierarchies. Functions declare what structure they need, and any record with that structure works.