Skip to the content.

Working with record fields in Haskell 2023

There are several ways to work with record fields in Haskell. The most traditional way is to use the ordinal pattern match and record field selectors.

{-# LANGUAGE RecordWildCards #-}

data Person = Person
  { name :: Name,
    age :: Int
  }
  deriving (Show)

data Name = Name
  { first :: String,
    last :: String
  }
  deriving (Show)

person :: Person
person = Person (Name "Tiger" "Scott") 10

f1, f2, f3, f4 :: String
Person {name = Name {first = f1}} = person
f2 = first $ name person
f3 = (\Person {name = Name {first}} -> first) person
f4 = (\Person {name = Name {..}, ..} -> first) person

a1, a2, a3, a4 :: Int
Person {age = a1} = person
a2 = age person
a3 = (\Person {age} -> age) person
a4 = (\Person {..} -> age) person

p1, p2 :: Person
p1 =
  person
    { name =
        (name person)
          { first = "Micheal"
          }
    }
p2 = person {age = 11}

As you can see, getting fields is simple and even simpler with NamedFieldPuns and RecordWildCards.

But it suddenly gets rather complex when it comes to updating nested fields.

Note that you can stop generating field selectors with NoFieldSelectors. This extension can be useful especially with DuplicateRecordFields, which allows you to have multiple fields with the same name in your module.

There is another way to get field values, which is HasField. Using HasField, you can access a field with a type-level string.

In addition to that, you can enable OverloadedLabels and add an orphan instance of IsLabel to use labels. For instance, you can write #name instead of getField @"name".

{-# LANGUAGE DataKinds, OverloadedLabels #-}

import GHC.Records
import GHC.OverloadedLabels

data Person = Person
  { name :: Name,
    age :: Int
  }
  deriving (Show)

data Name = Name
  { first :: String,
    last :: String
  }
  deriving (Show)

instance HasField x r a => IsLabel x (r -> a) where
  fromLabel = getField @x

person :: Person
person = Person (Name "Tiger" "Scott") 10

f1, f2 :: String
f1 = getField @"first" $ getField @"name" person
f2 = #first $ #name person

a :: Int
a = getField @"age" person

Unfortunately, HasField doesn’t support updating field values at this moment.

OverloadedRecordDot is another extension which allows you to access a field just like OO languages.

{-# LANGUAGE OverloadedRecordDot #-}

data Person = Person
  { name :: Name,
    age :: Int
  }
  deriving (Show)

data Name = Name
  { first :: String,
    last :: String
  }
  deriving (Show)

person :: Person
person = Person (Name "Tiger" "Scott") 10

f :: String
f = person.name.first

a :: Int
a = person.age

-- We need to implement setField by ourselves and enable OverloadedRecordUpdate to use these.
{-
p1, p2 :: Person
p1 = person {name.first = "Micheal"}
p2 = person {age = 11}
-}

As you can see, you can concatenate field names with . to access it. Note that it uses HasField under the hood.

There is another extension named OverloadedRecordUpdate, but you need to implement HasField type class by yourself to make it work.

The next option is lens (or other lens package). Even though the power of lens is far stronger than just accessing record fields, it works well just to get and set field values.

{-# LANGUAGE TemplateHaskell #-}

import Control.Lens

data Person = Person
  { _name :: Name,
    _age :: Int
  }
  deriving (Show)

data Name = Name
  { _first :: String,
    _last :: String
  }
  deriving (Show)

makeLenses ''Person
makeLenses ''Name

person :: Person
person = Person (Name "Tiger" "Scott") 10

f :: String
f = person ^. name . first

a :: Int
a = person ^. age

p1, p2 :: Person
p1 = person & name . first .~ "Micheal"
p2 = person & age .~ 11

While lens package uses Template Haskell to generate lenses such as name, age, first, and so on, generic-lens package uses GHC.Generics to generate lenses.

{-# LANGUAGE DataKinds, OverloadedLabels #-}

import Control.Lens
import Data.Generics.Labels ()
import Data.Generics.Product
import GHC.Generics

data Person = Person
  { name :: Name,
    age :: Int
  }
  deriving (Show, Generic)

data Name = Name
  { first :: String,
    last :: String
  }
  deriving (Show, Generic)

person :: Person
person = Person (Name "Tiger" "Scott") 10

f1, f2 :: String
f1 = person ^. field @"name" . field @"first"
f2 = person ^. #name . #first

a1, a2 :: Int
a1 = person ^. field @"age"
a2 = person ^. #age

p1, p2, p3 :: Person
p1 = person & field @"name" . field @"first" .~ "Micheal"
p2 = person & field @"age" .~ 11
p3 = person & #name . #first .~ "Micheal"

As you can see it uses a type-level string with field to specify a field instead of a generated lens. You can also import instances from Data.Generics.Labels to use labels instead, for instance, #name instead of field @"name".

There are some libraries that support record-like type if you take options to use non-standard records.

For example, extensible lets you to define a record type with Record and work with it using lenses.

{-# LANGUAGE DataKinds, OverloadedLabels #-}

import Control.Lens
import Data.Extensible

type Person = Record '[ "name" >: Name
                      , "age" >: Int
                      ]

type Name = Record '[ "first" >: String
                    , "last" >: String
                    ]

person :: Person
person = #name @= (    #first @= "Tiger"
                    <: #last @= "Scott"
                    <: nil
                  )
      <: #age @= 10
      <: nil

f :: String
f = person ^. xlb #name . xlb #first

a :: Int
a = person ^. #age

p1, p2 :: Person
p1 = person & xlb #name . xlb #first .~ "Micheal"
p2 = person & #age .~ 11

One thing you should know is that you need xlb when you combine lenses.

vinyl provides similar functionalities.

{-# LANGUAGE DataKinds, OverloadedLabels #-}

import Control.Lens
import Data.Vinyl
import Data.Vinyl.Syntax ()

type Person = FieldRec '[ "name" ::: Name
                        , "age" ::: Int
                        ]

type Name = FieldRec '[ "first" ::: String
                      , "last" ::: String
                      ]

person :: Person
person = #name =:= (     #first =:= "Tiger"
                     <+> #last =:= "Scott"
                   )
     <+> #age =:= 10

f :: String
f = person ^. #name . #first

a :: Int
a = person ^. #age

p1, p2 :: Person
p1 = person & #name . #first .~ "Micheal"
p2 = person & #age .~ 11

Note that these libraries provides much more functionalities than just replacing standard records. I picked up very basic usages in this post though.