Susan Potter
### snippets  ·  Created  ·  Updated

No Subtyping in PureScript: Row Polymorphism Instead

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 } -> Shape

Each 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 * radius

The 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.firstName

The 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.

AspectSubtypingRow Polymorphism
RelationshipDeclared inheritanceStructural compatibility
HierarchyFixed at definitionNone
ExtensibilityRequires modifying base classAny matching record works
CouplingTypes know their parentsTypes 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.