Record update syntax for newtype with typeclass instance

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! :slight_smile:

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

  1. unwrapping the records from the newtype, doing something to them, and then wrapping them again in the same constructor, to
  2. achieving the unwrapping/re-wrapping with coerce instead
  3. 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