Record, Row Types an Row Polymorphism

Record, or object in another programming languages, is usually defined as a list of product name => value pairs. The most common syntax to access record's properties is dot .

const obj = { x: 1, y: 2 }

console.log(obj.x) // 1

In Scala, we use class or case class, because object is singleton In Haskell, there is no "record type", it is only the syntactic sugar to a constructor type with functions that access it's fields

data Person = Person { name :: String, age :: Int }

-- syntactic sugar to
data Person = Person String Int

name :: Person -> String
name (Person name _) = name

age :: Person -> Int
age (Person _ age) = age

-- so, you can create a record in both ways
person :: Person
person = Person { name = "Mike", age = 1 }
-- or
person = Person "Mike" 1

Therefore, the most common issue in Haskell is namespacing for record field names. That means you can't define Person { name :: String } and Cat { name :: String } in same module. Function name will be duplicated. Moreover, Record in Haskell isn't extensible and polymorphism

PureScript is inspired of Haskell, however Record mechanism is different. It's constructor takes a row of concrete types. Same as JavaScript, we use dot to access properties.

data Record :: # Type -> Type

type Person = Record (name :: String, age :: Number)
-- or syntactic sugar with curly braces
type Person = { name :: String, age :: Number }

person :: Person
person = { name: "Mike", age: 1 }

log person.name

The fun here is Row Types, which make Record more flexible and overcome the issue of Haskell

Row Types and Row Polymorphism

Row Types is well explained in PureScript documentation:

A row of types represents an unordered collection of named types, with duplicates. Duplicate labels have their types collected together in order, as if in a NonEmptyList. This means that, conceptually, a row can be thought of as a type-level Map Label (NonEmptyList Type). Rows are not of kind Type: they have kind # k for some kind k, and so rows cannot exist as a value. Rather, rows can be used in type signatures to define record types or other type where labelled, unordered types are useful.

Therefore, Row Types can be extensible and polymorphism. It is denoted similar to Record, replaced with closed brackets. To denote an open row, separate the specified terms from a row variable by a pipe. It defines arguments for Record's constructor

-- closed row
type ClosedPersonRow = ( name :: String, age :: Number )
-- opened row
type OpenedPersonRow r = ( name :: String, age :: Number | r )

type Person r = Record (OpenedPersonRow r)

You have noticed polymorphic variable r, right? In other words, Person accepts any record which has properties name and age, and any other record properties.

type Pet = Record { species :: String | Person }

introduce :: { name :: String, age :: String | r } -> String
introduce { name, age } = name <> ", " <> show age <> " years old"

introduce { name: "Doraemon", age: 3, species: "dog" }
-- Doraemon, 3 years old

introduce { name: "Doraemon" }
-- Error: Type of expression lacks required label age.

However, the last one can't be compiled, because property age is missing.

It doesn't stop here. The real fun of Row Types is Type-Level Programming, which is introduced in the next section

Prim.Row

PureScript compiler also provides automatically solved type classes for working with row types. They help the code type-safe, generic, reduce duplicated codes, yet take advantage of compiler's power. You can read the documentation at Pursuit. However, you will be easier to grasp with simple examples.

purescript-record includes functions for working with records and polymorphic labels. Under the hood, these functions use Foreign Function Interface (FFI) from JavaScript object, and Row Constraints.

union function implement Union class. It returns record r3 including properties of both records r1, r2.

class Union (left :: # Type) (right :: # Type) (union :: # Type) 
| left right -> union, right union -> left, union left -> right

union :: forall r1 r2 r3. Union r1 r2 r3 => { | r1 } -> { | r2 } -> { | r3 }

union { x: 1, y: "y" } { y: 2, z: true }
:: { x :: Int, y :: String, y :: Int, z :: Boolean }

In record, labels can duplicated. To keep labels unique, use nub, implement of Nub constraint

class Nub (original :: # Type) (nubbed :: # Type) | original -> nubbed

nub :: forall r1 r2. Nub r1 r2 => { | r1 } -> { | r2 }

nub $ union { x: 1, y: "y" } { y: 2, z: true }
-- { x: 1, y: "y", z: true }

Note:

  • Nub is left hand priority. y :: String will be kept instead of right y :: Int. You have to think opposite if you come from JavaScript land.
  • nub is used for polymorphism records. In theory, record labels can be duplicated, but not in compiler. That means if you type { x: 1, y: "y", y: 2, z: true }, it can't compile.

merge is the combination of Union and Nub:

merge :: forall r1 r2 r3 r4. Union r1 r2 r3 => Nub r3 r4 => { | r1 } -> { | r2 } -> { | r4 }

merge { x: 1, y: "y" } { y: 2, z: true }
-- { x: 1, y: "y", z: true }

class Cons constraint helps the compiler know that, there is a property with label l. It is useful if you want to access a property in any record without know its constructor. get and set functions are most common use case of Cons


class Cons (label :: Symbol) (a :: Type) (tail :: # Type) (row :: # Type)
| label a tail -> row, label row -> a tail

get :: forall r r' l a
. IsSymbol l
=> Cons l a r' r
=> SProxy l
-> { | r }
-> a

get (SProxy :: SProxy "x") { x: 1, y: "y", z: true }
-- 1

set :: forall r1 r2 r l a b
. IsSymbol l
=> Cons l a r r1
=> Cons l b r r2
=> SProxy l
-> b
-> { | r1 }
-> { | r2 }


set (SProxy :: SProxy "x") "x" { x: 1, y: "y", z: true }
-- { x: "x", y: "y", z: true }

class Lacks is the opposite of Cons, a given Symbol label not existing in row. You may think of insert and delete functions that add/remove a property with label l from input record

class Lacks (label :: Symbol) (row :: # Type) 

insert :: forall r1 r2 l a
. IsSymbol l
=> Lacks l r1
=> Cons l a r1 r2
=> SProxy l -> a -> { | r1 } -> { | r2 }

insert (SProxy :: SProxy "x") "x" { y: "y", z: true }
-- { x: "x", y: "y", z: true }

delete :: forall r1 r2 l a
. IsSymbol l
=> Lacks l r1
=> Cons l a r1 r2
=> SProxy l -> { | r2 } -> { | r1 }

delete (SProxy :: SProxy "x") { x: "x", y: "y", z: true }
-- { y: "y", z: true }

You may have an idea "We can insert and delete without Lacks constraint". Yes, in theory we can. However, label can be duplicated. Inserting same property many times can cause unexpected results. Lacks constraint keep label unique.

When to use Row Constraints?

Unless you are developing libraries, or generics and type-level programming, you don't need them. Row polymorphism and purescript-record library is good enough for daily use. However, if you need to write FFI and reuse existed JavaScript library, row types is very useful. The most common use is optional record.

For example, default React components's props record are many optional attributes. You don't really need to construct all fields.

type SharedProps specific =
-- | `key` is not really a DOM attribute - React intercepts it
( key :: String

, about :: String
, acceptCharset :: String
, accessKey :: String
, allowFullScreen :: Boolean
...
| specific
)

However, if you make Props as parameter to create JSX element. You must fill all label values. It is really boring and useless.

p :: Record SharedProps_p -> JSX 

p { key: "", about: "", acceptCharset: "", accessKey: "", ... } -- uhh, too long

Before PureScript support row types, the popular idea is using Array Props to define React properties (Haskell DOM libraries is the same idea). It is still good. The flaw is attributes can be duplicated.

Since Row Constraints appearance, the issue has easily been solved.

p
:: forall attrs attrs_
. Union attrs attrs_ (SharedProps Props_p)
=> Record attrs
-> JSX

p { className: "text-center" } [ text "hello world"]

purescript-react-basic takes advantage of row types to make React simple, type-safe and more.

One common case is default values. In JavaScript, you will do it with:

const defaultValues = { x: 0, y: "" }

const result = (input) => Object.assign(defaultValues, input);

In PureScript, the code will be


type ObjectRow r = ( x :: Int, y :: String )
type OpenRow r = ( x :: Int, y :: String | r )

result ::
forall r1 r2
. Union r1 ObjectRow (OpenRow r2)
=> Nub (OpenRow r2) ObjectRow
=> Record r1 -> Record OpenRow
result r1 = merge r1 { x: 0, y: "" }

However, most of time we don't need to do with row types, if input directly

f :: Record OpenRow -> String

f (merge { x: 1 } defaultValues)
-- or
f defaultValues { x = 1 }

Does Haskell support Row Types?

Yes. There is library row-types. It is self-explain, right? However, there are another extensions that works with Record too. Row Types are better in PureScript because it is easy to do with FFI and JavaScript Object. The critical point is performance and complexity. We can't say which one is the best.

Conclusion

Because PureScript is a transpiler, working with FFI is unavoidable. Row Types help us to work with records easily in polymorphic way, Row Types are not only method to work with Record. purescript-variant provide similar functions. However, there are more fun things to do with Row family. RowList is the cool feature that support generic methodology to have fun with record. Justin Woo - a brilliant PureScript contributor - published a list of tutorials and talks about this topic. You can read his posts here