Record Updates Erase Tags

Something to watch for while using record updating syntax

Behavior

Let’s pop open GHCi and construct a record with a type tag.

We’ll want to be able to add some extra type annotations (thing :: type) in places we can’t normally, so let’s turn on ScopedTypeVariables

:set -XScopedTypeVariables

We want to make a type X with a tag t. t lets us differentiate between Xs by keeping information in the type, but without having to store any additional values. X also has a record with some stuff inside it, but the kind of stuff in X isn’t super relevant.

data X t = X { c :: Char, d :: Double }

Now that we have an X type, let’s make a value of type X.

myX :: X Int = X 'c' 1

Double-check the type of myX. Looks like it’s still hanging on to the Int tag.

:t myX
myX :: X Int

However, what happens when we try to update the record? Looks like Int has gone away, leading to the much-less-informative polymorphic t.

:t myX { d = 2 }
myX { d = 2 } :: X t

We lost our tag due to the record update! This might be confusing in real code, since we can do things like assign what was just previously an X Int to an X String. Because the record update doesn’t keep the tag around, the tag’s usefulness diminishes completely in this case.

x2 :: X String = (myX :: X Int) { d = 2 }

Why?

To understand why this happens, let’s write a similar updater function ourselves.

replaceC is a function that takes an X and replaces the c inside. It acts just like a record update.

replaceC (X c d) newC = X newC d

Also just like a record update, replaceC doesn’t ensure that the tag sticks around.

:t replaceC myX 'o'
replaceC myX 'o' :: X t2

Looking at its type, we can see why:

:t replaceC
replaceC :: X t1 -> Char -> X t2

The most general type for replaceC allows t1 and t2 to be different (and GHC loves to be as general as it can). To say that they should be the same is an additional restriction. However, in the case of keeping tags around, it’s really nice not to lose the tag just because you updated some other part of the data structure.

By adding a type signature specifying the restriction that replaceC should preserve t, we get to keep tags even when we replace bits of the record.

replaceC :: X t -> Char -> X t
replaceC (X c d) newC = X newC d

However, I wasn’t able to find a great way to do this tag preservation while still using the record update syntax (which is essentially just some syntactic sugar over a function kind of like replaceC). Hopefully someone knows a good trick for this.