United Kingdom: +44 (0)208 088 8978

5 things to be aware of with F# anonymous records

In our previous post on Anonymous Records, we looked at some of the benefits of working with them. In this post, I want to discuss five "gotchas" that you should be aware of when using anonymous records that you may not have considered.

Limited type inference

Unlike standard F# records, the F# compiler won't look at instances of anonymous records that have been declared, nor type aliases, when performing type inference. This normally isn't a problem when returning anonymous records out of e.g. a function, but when taken as an input, it's invariably an issue.

let foo = {| Name = "Isaac" |}

// error FS0072: Lookup on object of indeterminate type based on information prior to this program point. A type annotation may be needed prior to this program point to constrain the type of the object. This may allow the lookup to be resolved.
let bar x =
    x.Name

bar foo

You can resolve this either by explicitly entering a type annotation, or by using a type alias:

// works
let bar (x:{|Name:string|}) = x.Name

// also works
type FooData = {| Name : string |}
let bar (x:FooData) = x.Name

Often the latter will be preferable to improve readability, and acts as a useful "bridge" when moving towards full records.

Unclear error messages

Error messages with anonymous records are different to those for standard records, and in F#4.7 are not always particularly easy to reason about.

type FullRecord = { Name : string; Age : int }

// error FS0764: No assignment given for field 'Age' of type 'FullRecord'
let x:FullRecord = { Name = "Isaac" }

type AnonymousRecord = {| Name : string; Age : int |}

// error FS0001: Two anonymous record types have mismatched sets of field names '["Age"; "Name"]' and '["Name"]'
let y:AnonymousRecord = {| Name = "Isaac" |}

Observe how the former understands what record type is required and explains what field(s) are missing. The latter requires you to scan through all fields to identify the difference between both lists. If you have more than just a few fields, and more one difference, it can quickly become very, very difficult to identify what those differences are. Thankfully this issue has been fixed and will be included in the next version of F#. Other issues still exist with error messages, such as this one - I hope that such improvements are implemented in time.

No pattern matching

You cannot pattern match on anonymous record fields, even if you use type annotations to tell the compiler which record is being used. You can work around this to some extent by using Active Patterns.

let (|Name|_|) v (x:AnonymousRecord) = if x.Name = v then Some() else None
let (|Age|_|) v (x:AnonymousRecord) = if x.Age = v then Some() else None
let greet (r:AnonymousRecord) =
    match r with
    | Name "Test" & Age 20 -> "Hello!"
    | _ -> "Goodbye"

foo {| Name = "Isaac"; Age = 21 |} // Goodbye
foo {| Name = "Test"; Age = 20 |} // Hello!

No members

You can't put members on anonymous records. Again, there's a workaround using Extension Methods.

open System.Runtime.CompilerServices

let y:AnonymousRecord = {| Name = "Isaac"; Age = 21 |}

[<Extension; AbstractClass; Sealed>]
type Extensions =
    [<Extension>]
    static member Description (ty: AnonymousRecord) =
        sprintf "%s is %d years old" ty.Name ty.Age

y.Description() // Isaac is 21 years old

You cannot use type extensions; only extension methods are allowed.

Limited composability

Whilst anonymous records appear to be structural records with e.g. static duck typing and so on, they aren't - they're still nominal records that are defined statistically.

let p1 = {| Name = "Fred" |}
let p2 = {| Name = "Tim" |}
let p3 = {| Name = "Frank"; Age = 34 |}
p1.GetType() = p2.GetType() // true
p1.GetType() = p3.GetType() // false

So, whilst the compiler does support some nice features, be aware that there are limits. For starters, you can construct one anonymous record as a superset of another, but you can't compose more than one "source" record, and nor can you create "subset" records.

let a = {| Fruit = "Bananas" |}
let b = {| Color = "Yellow" |}
let c = {| a with Climate = "Hot" |} // allowed
let z = {| a with b with Age = 1 |} // not allowed

You also cannot do things such as this:

let capitaliseFruit (x:{|Fruit: string|}) =
    x.Fruit.ToUpper()

capitaliseFruit c // not allowed - c has an extra Climate field

Conclusion

Anonymous Records are an extremely useful tool in the arsenal of an F# developer. They provide an excellent alternative to Tuples in terms of documentation and readability, some unique features that "full" records don't have, and a smooth migration path towards full records when required.

However, there are definitely some limitations over full records - although these often have workarounds by using alternative language features. Often, type annotations and hints can get you quite far, although if you're continuously resorting to these atypical workarounds, I would advise considering moving to full records.