Anonymous records were introduced in F# 4.6, to augment regular (nominal) F# records. We've been using them heavily in a recent project and thought it worth sharing some of our exeriences with them, both positive and negative. Certainly there are some situations that we've been using them that we simply didn't expect at first - here's our top five benefits for working with them.
1. Easily JSON serialization
One benefit we've seen is that they can be used as a lightweight "outwards" serialization structure. This is useful for e.g. writing JSON files, or exposing JSON data from an HTTP endpoint. F# types often make use of e.g. Discriminated Unions or similar, which do not often lend themselves well to some serialization libraries - or perhaps you need to conform to a specific schema for your JSON object. Take this example:
type CustomerId = CustomerId of int member this.Value = match this with CustomerId x -> x
type Title = Title of string member this.Value = match this with Title x -> x
type Name = FirstName of string | Fullname of string * string
type Customer = { Id : CustomerId; Title : Title option; Name : Name; Age : int }
let customer = { Id = CustomerId 123; Title = Some (Title "Mr"); Name = FirstName "Fred"; Age = 23 }
Newtonsoft serializes customer
above into the following:
{
"Id": {
"Case": "CustomerId",
"Fields": [
123
]
},
"Title": {
"Case": "Some",
"Fields": [
{
"Case": "Title",
"Fields": [
"Mr"
]
}
]
},
"Name": {
"Case": "FirstName",
"Fields": [
"Fred"
]
},
"Age": 23
}
in such cases, you might want more control for shaping the JSON, and in this case anonymous records can be a great fit:
let y =
{| Id = customer.Id.Value
Title = customer.Title |> Option.map(fun t -> t.Value) |> Option.defaultValue null
FirstName = match customer.Name with FirstName x | Fullname (x, _) -> x
LastName = match customer.Name with FirstName _ -> null | Fullname (_, y) -> y
Age = customer.Age |}
This now renders with a much simpler shape, with the bulk of the business logic mapping managed through type-safe F#.:
{
"Age": 23,
"FirstName": "Fred",
"Id": 123,
"Title": "Mr"
}
This has always been possible in F#, but often the cost of creating custom F# records for every JSON type was too onerous (and added verbosity to the codebase). With anonymous records, you don't need to worry about the cost of defining a type.
2. Structural creation
Anonymous records also allow you to quickly create new anonymous records that are supersets of existing records (whether anonymous or not):
let z = {| customer with DateOfCreation = DateTime(2020, 1, 6) |}
This is handy if you need to (perhaps temporarily within a limited scope) add some data to an object in a quick and easy way instead of resorting to Tuples.
3. Nested Records
You can also mix and match nominal and anonymous records in a single type. This is especially useful when defining nested records on a type:
type Customer =
{ Id : CustomerId
Title : Title option
Name : {| FirstName : string; MiddleName : string option; LastName : string |}
Age : int }
By placing the definition of the Name
field inline, we can reduce cognitive overhead of what this Customer
type looks like by not having to refer to another type. Obviously, use this with care - for larger structures, it will probably still be useful to create a dedicated type for the nested record.
4. Type aliases
A hybrid middle-ground between anonymous and nominal records is to alias the anonymous record type:
type NameDetails = {| FirstName : string; MiddleName : string option; LastName : string |}
type OtherCustomer =
{ Id : CustomerId
Title : Title option
Name : NameDetails
Age : int }
This allows you to continue to benefit from some of the unique features of anonymous records whilst benefitting from the ability to provide type annotations to the compiler as required.
5. Slowly changing shapes
There's one more benefit that anonymous records give you: the ability to slowly migrate two types away from one another. A common situation we often see is where you have two sections of your assembly and wish to enforce some kind of separation at the type level - even though the shapes on both "side" may look the same. Here's an example of achieving this with standard F# records:
module ApiLayer =
type Customer =
{ Id : System.Guid
Title : string
Name : string
Age : int }
module DataLayer =
type Customer =
{ Id : System.Guid
Title : string
Name : string
Age : int }
static member OfApiLayer (c:ApiLayer.Customer) : Customer =
{ Id = c.Id
Title = c.Title
Name = c.Name
Age = c.Age }
let (apiCustomer:ApiLayer.Customer) = { Id = Guid.Empty; Title = "Title"; Name = "Name"; Age = 34 }
let (dataCustomer:DataLayer.Customer) = DataLayer.Customer.OfApiLayer apiCustomer // explicit translation
The cost of this is the boilerplate OfApiLayer
method that translates from one "layer" of the application to another (and another going in the opposite direction). With anonymous records, this problem completely goes away, since both Customer
types in the two namespaces are indeed the same type - they are simply aliased in two places:
module ApiLayer =
type Customer =
{| Id : System.Guid
Title : string
Name : string
Age : int |}
module DataLayer =
type Customer =
{| Id : System.Guid
Title : string
Name : string
Age : int |}
let (apiCustomer:ApiLayer.Customer) = {| Id = Guid.Empty; Title = "Title"; Name = "Name"; Age = 34 |}
let (dataCustomer:DataLayer.Customer) = apiLayerValue // no conversion needed!
From here, you can elect when and how to diverge types between API and Data layers - and only pay the cost of conversion as and when needed, rather than up-front. As an added bonus, since there's no actual generation of new objects, there's a small performance benefit to be had here as well!
Conclusion
Hopefully you found these tips useful. In the next post, we'll cover five gotchas to be aware of when working with anonymous records - it's not a free lunch!