Is wrapping records with newtype still considered best practice?
I’m working on updating the AddresssBook example for ch8 of the purescript book, and see this pattern:
newtype AppState = AppState
{ person :: Person
, errors :: Errors
}
In every other project I’ve come across, state is just a record, such as:
type AppState =
{ person :: Person
, errors :: Errors
}
I’m noticing that dealing with newtype wrappers tends to require verbose Named Patterns, versus simple nested record updates. Here’s an example of updating the firstName field in a reducer (react-basic-hooks).
-- newtype wrapper
reducer :: AppState -> Action -> AppState
reducer (AppState innerState@{person: Person innerPerson}) = case _ of
GotUpdate -> AppState innerState { person = Person innerPerson { firstName = "updated" } }
-- no wrapper
reducer :: AppState -> Action -> AppState
reducer state = case _ of
GotUpdate -> state { person { firstName = "updated" } }
I understand how newtype wrappers are great for String or other easily-confused datatypes, but it seems unnecessary for custom record types[1]. Perhaps that’s why they seem to be rarely used for records today.
So before I make more significant changes to the purescript book code, I wanted to check for community consensus. What are your thoughts on wrapping records with newtype, and should this style be used in the purescript book?
[1] An exception might be all the different meanings of {x,y} (vector, translation, etc.) that you’d find in a geometry library).
For me it depends on the scope. For most things, I don’t, but records can lead to verbose, hard to follow errors for nested state. For example, we use a central state store for our app, and it’s pretty flat (a key for each feature), but we newtype the nested state, but then write our reducers such that it automatically wraps/unwraps it. We code-generate our API bindings, and we generate newtypes for everything because it leads to clearer errors, and you can go-to-definition on the constructors.
It’s a little sad that errors are a reason for avoiding using bare records. I saw there was some work recently on improving error messages in records - is the error reporting issues just a natural implication of the nature of records? Or could there be further improvements to it?
I wouldn’t recommend any documentation being so presumptuous of the reader’s use-case so much so that it simply says “you should always newtype your records”, as best practices can be followed blindly and restrict good code rather than help it avoid inadvertent pitfalls.
I don’t have a concrete answer containing best practices, but I do have relevant food for thought.
I think records can be a good option when seeking certain code-level designs. Specifically, if you’d like your functions to work with an “unnamed” data type, records are an interesting alternative to type classes. I’ve always found type classes a little “aggressive” in some code I’ve read, so I find interesting any design which provides an alternative to using them.
While experimenting with stripping away the newtype wrapper from the AddressBook code, I found that these were necessary for Show instances.
So I think those wrappers will have to stick around, which makes deep record updates a bit more tedious. At least it’s not as bad as having AppState wrapped too.
I assume there’s no way to describe how to show a particular record type without creating a newtype for it.
I think that it could be helped if the type checker preserved synonym names. All these records are defined as synonyms, and so they are nice to write, but the compiler thinks it’s OK to just forget that.
I was wondering if there’s a way to customize how particular records are shown. For example to port the existingtypeclass version:
instance showAddress :: Show Address where
show (Address o) = "Address " <>
"{ street: " <> show o.street <>
", city: " <> show o.city <>
", state: " <> show o.state <>
" }"
To something that works on the record synonym:
show :: Address -> String
show o =
"Address " <>
"{ street: " <> show o.street <>
", city: " <> show o.city <>
", state: " <> show o.state <>
" }"
The latter example assumes:
newtype Address = Address
{ street :: String
...
is converted to:
type Address =
{ street :: String
...
I see how there are lots of problems with expecting the latter to work. For example if you implement a custom show for {foo :: String} and another custom show for {bar :: String}, then try to show {foo :: String, bar :: String}, which custom show would the compiler pick? Maybe these custom shows could only work with closed records, but then there are likely other issues.
I worked a bit on improving record errors (not showing the whole records but just the row diffs). There are still a few things to fix, but I’m waiting for the golden tests pr to be merged.
I don’t think, however, that this will improve the situation drammatically
I also reach for newtypes when I want to preserve the name of something for error reporting. Is there a particular reason beyond no one yet working on it for type synonyms to be forgotten by the compiler?
It seems that expanding the synonyms could happen later on, preserving the names, and perhaps that verbose errors could expand them.
It seems that expanding the synonyms could happen later on, preserving the names, and perhaps that verbose errors could expand them.
I mentioned this briefly in the PolyKinds PR, but I would like to make synonym expansion part of kind elaboration, expanding to a type that is tagged with it’s source-written type. This would allow us to typecheck as usual, but note the user written type in errors.
It would be nice for Purescript to have a special support for newtypes wrapping records, since that use case comes up so often. Haskell gets that one right IMO.
A bit crazy idea but can I suggest a special . syntax for getting fields out of newtype wrapped records? Say using : instead -
I would like to be able to do the following in Purescript -
newtype AppState = AppState
{ person :: Person
, errors :: Errors
}
newtype Person = Person {name::String}
username :: AppState -> String
username a = a:person:name
Poor role model but this is similar to how C distinguishes between direct struct access struct.field vs accessing a field through a struct pointer structpointer->field. Of course in Purescript using -> would be very confusing, so I thought of the :.
Since : is already a valid operator I think you could achieve this in a library with a type class. The second argument would have to be a proxy of course, but I don’t think this justifies language changes anyway, especially since the fact that : is already a valid operator name would mean that this would be breaking.
I think the solution with the most promise is to incur a Coercible constraint to a Record when we are checking (not inferring) against a known type that is not a Record. The main concern around a newtype/record correspondence is how one keeps good inference behavior around record syntax without incurring a bunch of constraints. That is:
example a = a.foo
Should continue to infer as
forall t1 t2. { foo :: t2 | t1 } -> t2
Rather than a bunch of constraint garbage. However, I’m not necessarily opposed to add checking rules that can incur constraints when a user has otherwise provided enough type annotations to fix a terms type to some known constructor. If we know the term should not be a bare Record due to annotations, then a Coercible constraint can be raised, since such a term could type check if it has the same runtime representation as a Record. I would be interested to see how this affects type errors, and how they could be improved in these cases (I think it’s likely surmountable, since the existing errors aren’t great). I’m not saying this would be merged or accepted, but I think it would at least be beneficial to prototype so that we can have a clear answer on this topic regardless.
I am quite skeptical of this approach too, actually. Is there any place where we are inferring less general types for top level expressions than the most general type they could be annotated to have currently? I would expect that if I delete all of the top level type signatures in a file and let the compiler infer them, I should never end up with a less general type than I had beforehand.
It may be the case that the errors are not much worse than they are now, but I think in these discussions the comparison ought to be between how good error messages could potentially be with versus without the feature, rather than how good they actually are with vs without it right now; what I wouldn’t want is for us to add this and then as a result of that make it much harder to improve record type errors in the future.
It’s certainly a tradeoff. It’s basically saying “we won’t infer a Coercible constraint, but we will attempt to use one if we have enough type-information to decide”. It’s similar to “quick look impredicativeity” in recent GHC which will instantiate polytypes with other polytypes if it has the information in the type environment at the time of instantiation. It isn’t a complete solution, and will fail to type-check in complex cases, but will work in the vast majority of cases. You can alternatively always raise constraints and apply defaulting rules, which is equivalent to discarding a more general type for a more specific one.