This is a short post documenting various record-related idioms in the Haskell ecosystem. First-time package users can use this post to better understand record API idioms they encounter in the wild.
For package authors, I also include a brief recommendation near the end of the post explaining which idiom I personally prefer.
The example
I’ll use the following record type as the running example for this post:
There are a few ways you can create a Person
record if the package author exports the record constructors.
The simplest approach requires no extensions. You can initialize the value of every field in a single expression, like this:
Some record literals can get quite large, so the language provides two extensions which can help with record assembly.
First, you can use the NamedFieldPuns
extension, to author a record like this:
{-# LANGUAGE NamedFieldPuns #-}
example :: Person
example = Person{ name, admin }
where
name = "John Doe"
admin = True
This works because the NamedFieldPuns
extension translates Person{ name, admin }
to Person{ name = name, admin = admin }
.
The RecordWildCards
extension goes a step further and allows you to initialize a record literal without naming all of the fields (again), like this:
{-# LANGUAGE RecordWildCards #-}
example :: Person
example = Person{..}
where
name = "John Doe"
admin = True
Vice versa, you can destructure a record literal in a few ways. For example, you can access record fields using accessor functions:
render :: Person -> String
render person = name person ++ suffix
where
suffix = if admin person then " - Admin" else ""
… or you can pattern match on a record literal:
render :: Person -> String
render Person{ name = name, admin = admin } = name ++ suffix
where
suffix = if admin then " - Admin" else ""
… or you can use the NamedFieldPuns
extension (which also works in reverse):
render :: Person -> String
render Person{ name, admin } = name ++ suffix
where
suffix = if admin then " - Admin" else ""
… or you can use the RecordWildCards
extension (which also works in reverse):
render :: Person -> String
render Person{..} = name ++ suffix
where
suffix = if admin then " - Admin" else ""
Also, once the RecordDotSyntax
extension is available you can use ordinary dot syntax to access record fields:
render :: Person -> String
render person = person.name ++ suffix
where
suffix = if person.admin then " - Admin" else ""
Opaque record types
Some Haskell packages will elect to not export the record constructor. When they do so they will instead provide a function that initializes a record value with all required fields and defaults the remaining fields.
For example, suppose the name
field were required for our Person
type and the admin
field were optional (defaulting to False
). The API might look like this:
module Example (
Person(name, admin)
, makePerson
) where
data Person = Person{ name :: String, admin :: Bool }
makePerson :: String -> Person
makePerson name = Person{ name = name, admin = False }
Carefully note that the module exports the Person
type and all of the fields, but not the Person
constructor. So the only way that a user can create a Person
record is to use the makePerson
“smart constructor”. The typical idiom goes like this:
In other words, the user is supposed to initialize required fields using the “smart constructor” and then set the remaining non-required fields using record syntax. This works because you can update a record type using exported fields even if the constructor is not exported.
The wai
package is one of the more commonly used packages that observes this idiom. For example, the Request
record is opaque but the accessors are still exported, so you can create a defaultRequest
and then update that Request
using record syntax:
… and you can still access fields using the exported accessor functions:
This approach also works in conjunction with NamedFieldPuns
for assembly (but not disassembly), so something like this valid:
example :: Request
example = defaultRequest{ requestMethod, isSecure }
where
requestMethod = "GET"
isSecure = True
However, this approach does not work with the RecordWildCards
language extension.
Some other packages go a step further and instead of exporting the accessors they export lenses for the accessor fields. For example, the amazonka-*
family of packages does this, leading to record construction code like this:
example :: PutObject
example =
putObject "my-example-bucket" "some-key" "some-body"
& poContentLength .~ Just 9
& poStorageClass .~ ReducedRedundancy
… and you access fields using the lenses:
My recommendation
I believe that package authors should prefer to export record constructors instead of using smart constructors. Specifically, the smart constructor idiom requires too much specialized language knowledge to create a record, something that should be an introductory task for a functional programming language.
Package authors typically justify smart constructors to improve API stability since they permit adding new default-valued fields in a backwards compatible way. However, I personally do not weight such stability highly (both as a package author and a package user) because Haskell is a typed language and these changes are easy for reverse dependencies to accommodate with the aid of the type-checker.
I place a higher premium on improving the experience for new contributors so that Haskell projects can more easily take root within a polyglot engineering organization. Management tends to be less reluctant to accept Haskell projects within their organization if they feel that other teams can confidently contribute to the Haskell code.
Future directions
One long-term solution that could provide the best of both worlds is if the language had first-class support for default-valued fields. In other words, perhaps you could author a record type like this:
… and then you could safely omit default-valued fields when initializing a record. Of course, I haven’t fully thought through the implications of such a change.
In all `render` examples except the first, I think the condition should be `admin`, not `admin person`.
ReplyDeleteThank you for pointing that out! It's fixed now
DeleteCould it be that the where clauses should begin with `suffix = `?
ReplyDeleteYou're right. They should be fixed now
Deletes/This words because/This works because
ReplyDeleteThank you! I fixed it
Delete