Field selectors for newtypes

I’ve recently updated a large codebase from 8.10.7 to 9.2.5, enabling -XNoFieldSelectors and -XOverloadedRecordDot everywhere, rewriting selectors to dot syntax where necessary.

During the process I had one observation which I think is worth some discussion. What I observed is that from the semantics / operational point of view, newtype accessors are not really fields. Consider the following types

newtype Key a = Key { unKey :: a }
newtype MyMonad r a = MyMonad { runMyMonad :: r -> IO a }

I must say that something was not feeling right when I had to type key.unKey or m.runMyMonad env. It feels much more natural to unKey key, or runMyMonad m env. Usually when I create a newtype, I think in terms of “actions” like “unwrap the type” or “run the monad”, not necessarily “get the unKey field” or “access the run function of this monad”. A few times I explicitly turned field selectors on on a module that declares a newtype, to retain the previous behaviour.

Overall, it summarizes to the following opinion: newtypes are not really records, and maybe their unwrap functions should not be treated as selectors (in light of -X(No)FieldSelectors extension). What are your opinions on this topic?

2 Likes

I don’t mind having them as fields, but I did find myself renaming them. While I would previously have:

newtype FooId = FooId {unFooId :: Int64}

I find myself now writing:

newtype FooId = FooId {int64 :: Int64}

Then I can do things like person.fooId.int64 to get the underlying Int64.

3 Likes

I agree that newtypes with fields are a bit strange. The constructor and field selector are an isomorphism, so each determines the other.

Not sure what others do, but personally I wouldn’t usually use a newtype field in a pattern match (e.g. f (Key{unKey = x} = ...) and there is little point in using them in record updates because you are replacing the whole value anyway (i.e. r { unKey = x } is the same as Key x). Thus perhaps the thing to do is to define a non-field newtype and its selector explicitly?

newtype Key a = Key a

unKey :: Key a -> a
unKey (Key k) = k

One downside I see is that importing/exporting Key(..) will no longer include unKey, which might be a problem for migrating an existing codebase.

2 Likes

To import everything with Key(..), you could bundle a pattern synonym.

{-# language PatternSynonyms #-}

module M ( Key(.., unKey) ) where

newtype Key a = Key a

pattern KeyP{ unKey } <- Key unKey

I often find the un... name not very helpful – I see why @Swordlash uses runMyMonad, but to not at all include the name of the newtype seems … well, weird

What if some other newtype wraps a int64? What if some newtype wraps a pair of int64?

I find that not an improvement.

I think there’s proposals to make -XNoFieldSelectors more specific to one datatype (or {-# FieldSelectors #-} annotation enabled for one datatype but not in general). So I guess enabling them for newtypes only would fall within that general direction.

The problem would be name clashes. If you always name your newtype's field for the type (like @ocharles isn’t), that won’t be a problem.

Yeah, the general direction that sounds nice to me would be something like -XNewtypeSelectors which generates selectors for datatypes, but not for newtypes. But I guess adding another extension just for that may be too much.

What if some other newtype wraps a int64?

Multiple newtypes wrapping the same field name is solved by using DuplicateRecordFields.

What if some newtype wraps a pair of int64?

Then I wouldn’t call the field int64.