Hello,
I’m confused about the record update syntax for newtypes with a class instance. I come from Haskell and these small differences just feels off.
Below, Point1
is a newtype that represents a record. Constructing p1
and updating it to p2
has no surprises for me.
But when I try to do the same thing for essentially the same newtype Point2
, with a typeclass instance for Default
, apparently I have to unwrap the newtype before updating it. It looks like it has turned into a datatype (declared with data
). I suppose it makes sense to turn a newtype into a datatype to support instances on it, but then why not prohibit newtypes with typeclasses?
Can someone please explain it to me? Thank you
newtype Point1 = Point1 {x1 :: Int, y1 :: Int}
newtype Point2 = Point2 {x2 :: Int, y2 :: Int}
class Default a where
def :: a
instance defaultP2 :: Default Point2 where
def = Point2 {x2: 1, y2: 2}
main :: Effect Unit
main = do
-- No surprises here
let p1 = {x1: 1, y1: 1}
let p2 = p1 {x1 = 1}
-- Using a default from a typeclass...
let d = (def :: Point2)
-- Or just constructing it normally (but type name needed now)...
--let d = Point2 {x2: 2, y2: 8}
-- ...doesn't work:
--let du = d { x2 = 3 }
-- Could not match type
-- { x2 :: t0
-- | t1
-- }
--with type
-- Point2
let du = (let (Point2 c) = d in Point2 (c {x2 = 3}))
log "🍝"
Ah. Try giving p1
and p2
type signatures like:
main :: Effect Unit
main = do
let
p1 :: Point1
p1 = {x1: 1, y1: 1}
let
p2 :: Point1
p2 = p1 {x1 = 1}
...
You’ll see that you haven’t actually been creating & updating something of type Point1
in your example. It won’t compile.
You’ve actually just been creating & updating something of type { x1 :: Int, x2 :: Int }
.
i.e. this will compile.
let
p1 :: { x1 :: Int, y1 :: Int }
p1 = {x1: 1, y1: 1}
let
p2 :: { x1 :: Int, y1 :: Int }
p2 = p1 {x1 = 1}
If you want it to work with the Point1
signatures, you need to unwrap the newtype
to operate on what it contains, just like in Haskell with its newtype
's.
e.g. given these definitions
newtype Point1 = MkPoint1 {x1 :: Int, y1 :: Int}
newtype Point2 = MkPoint2 {x2 :: Int, y2 :: Int}
and these functions
unwrapPoint1 :: Point1 -> {x1 :: Int, y1 :: Int}
unwrapPoint1 (MkPoint1 rec) = rec
unwrapPoint2 :: Point2 -> {x2 :: Int, y2 :: Int}
unwrapPoint2 (MkPoint2 rec) = rec
this would work:
main :: Effect Unit
main = do
let
p1 :: Point1
p1 = MkPoint1 {x1: 1, y1: 1}
let
p2 :: Point1
p2 = MkPoint1 $ (unwrapPoint1 p1) {x1 = 1}
You can work around it with over
/under
from Data.Newtype
etc. though.
Or coerce
^^^ do this!
1 Like
Ah! I totally forgot about “anonymous”/standalone record types in purescript. I should’ve remembered to put types on anything mysterious first. Thanks for the help!
1 Like
Yes — while you can unwrap / wrap the newtypes, records are really nice in PureScript so it’s much more common to just work with bare records directly and use type synonyms if you need to name them.
2 Likes
No problems! I figure you’ll want to be merging together those x
and y
points from different newtype-wrapped records together at some point? If so, I’ve made a Gist here with some examples.
It builds up from just
- unwrapping the records from the
newtype
, doing something to them, and then wrapping them again in the same constructor, to
- achieving the unwrapping/re-wrapping with
coerce
instead
- using the existing functions from
Data.Newtype
(here) like over
, under
, over2
, under2
etc.
e.g. building up to something like
newtype Point1 = MkPoint1 {x1 :: Int, y1 :: Int}
derive instance Newtype Point1 _
newPoint1_A :: Point1
newPoint1_A = MkPoint1 { x1: 3000, y1: 4000 }
newPoint1_B :: Point1
newPoint1_B = MkPoint1 { x1: 777, y1: 888 }
addInnerRecords
:: {x1 :: Int, y1 :: Int}
-> {x1 :: Int, y1 :: Int}
-> {x1 :: Int, y1 :: Int}
addInnerRecords = under2 Additive (<>)
mergedPoint1 :: Point1
mergedPoint1 = over2 MkPoint1 addInnerRecords newPoint1_A newPoint1_B
and
λ> logShow mergedPoint1
MkPoint1 { x1: 3777, y1: 4888 }
records are really nice in PureScript
Very true! I’ve been watching the ~recent/upcoming record dot syntax changes in Haskell and it still doesn’t look like it will be nearly as nice to work with as PureScript’s bare records are.
I initially tried to use bare records with a type alias, but to create class instances it insists I have to name them with a wrapper
@oJHKqy4m
Quoting natefaubion from the Discord (www.purescript.org/chat or this invite link) here:
The compiler restricts instances for concrete rows like this.
- They are very often orphan instances (since they are often not defined with the class, and
Record
is internal to the compiler).
- It encourages you to write a fully generic instance (forall rows in the Record) (edited)
For example, there is a Show instance for Record, as long as all the fields in it are also Show. Same with Eq
, Semigroup
, Monoid
, etc.
And a few days later someone else asked:
Am I correct that theres no way to implement type classes on records? I have to wrap the record in a newtype right?
To which Nate responded:
You cannot implement an instance for a specific concrete set of rows. You can implement them for Records “generally”, such as we do with Show, Eq, etc.
That is to say, you can implement instances, but there can be only one (for some Record r
).
Additionally, you can only do so for classes that you have written, since the instance would need to be paired with the class to avoid an orphan instance. (edited)
You can’t do something like instance Show { width :: Int, height :: Int } where...
I initially tried to use bare records with a type alias, but to create class instances it insists I have to name them with a wrapper
So it is possible to have a Default
class and have it work for records, like you original attempt at doing type synonyms for your Point
type.
BUT there will only be one Default
instance for all records.
And that’s probably going to be implemented by constructing it instance for the Record
out of the Default
instance for each of its field types.
Like this Default
class example here:
https://try.purescript.org/?gist=44aa5fd7d9a985eb591296331d38ba48
And so that means you only get to specify one Default
value for every Int
, which would mean your x1 :: Int
, y1
, x2
, y2
default values would all be the same. At least, under this implementation of the Data.Default
typeclass in the Gist.
It’s why you can use show
on the records in that main
block. i.e. because there is a Show
instance for each of the field types in the records (String
, Array String
, Int
, etc.), the one single Show
instance for Record r
can render the entire thing.
But as soon as you add in a new data type field that doesn’t have an instance, it can no longer construct it for the entire record. e.g. see the commented-out FavoriteGenre
field in one of the records in the Gist.
/spam over
1 Like