Help modeling OO in purescript: understanding row types and their use in newtypes for different behaviors

Context

I’m creating GoJS bindings for PureScript, which requires me to model an extremely objected-oriented design in a pure FP setting, and one of the things I’m trying to do is prevent things like GoJS’ Panel class’ redundant fields - every Panel can be one of several types (Auto, Horizontal, and others) and if it is of a certain type, certain fields in the class are simply non-existent. In the Typescript type declarations for the class, they just create a huge product type with all fields and basically tag it in the docs as “this field only makes sense for a certain type”. In PureScript, this would normally mean a sum type, but then it is simply much harder to model inheritance (a Node can also be Auto, Horizontal etc since it inherits from Panel).

The solution I came up with is by using a combination of newtypes for the class hierarchy, symbols for the Panel types and row types for the extra fields.

This gives me a little hierarchy of row types like below (the Small prefixes are a minimal example for the sake of this question, the actual types are huge):

type SmallPanelFields (a :: Row Type) =
  (
    isEnabled :: Boolean
  , itemIndex :: Number
  | a  
  )

type SmallPartFields (a :: Row Type) =
  ( text :: String
  , textEditable :: Boolean
  | a
  )

type SmallNodeFields (a :: Row Type) =
  ( avoidable :: Boolean
  , isLinkLabel :: Boolean
  | a )

newtype SmallNode (panelType :: Symbol) (extraPanelFields :: Row Type) = SN (
  Record (
    SmallPanelFields (
      SmallPartFields (
        SmallNodeFields extraPanelFields
      )
    )
  )
)

Then I can implement something like

defaultNode :: SmallNode "Auto" ()
defaultNode = SN { ... }

And all node-specific behaviors as instances of typeclasses that only include coherent combinations of “extra fields” and the symbol. For instance, there is a type of SmallNode “Table” for which there are a bunch of methods

class TableMethods a where
  addRowColumnDefinition :: a -> RowColumnDefinition -> Effect Unit
  -- ... other table-exclusive behaviors

And an instance for Node "Table" TableFields can be given, but not for any other combination of symbol and extra fields row.

I don’t know if this is the best solution to this (there’s still the issue of holding heterogenous lists of nodes of different types for instance, which will be required at some point), but it is the one I’m happiest with for now. Sorry for the long context, but I hope it helps ground my question!

The problem

There is then an issue of sending this data to the FFI which actually creates the js classes. In PureScript, we want to use things like Maybe and List, which should correspond to Nullable and an Iterator class that GoJS provides. Since these records have tons of fields, I’m trying to avoid writing all this by hand and want to instead map over all fields of a record with another typeclass called ToFFI. For this I’m using heterogeneous, thanks to a Reddit suggestion, like so:

class FFIMap a b where
  ffi :: a -> b

instance maybeToNullable :: FFIMap a b => FFIMap (Maybe a) (Nullable b) where
  ffi = toNullable <<< map ffi

else instance intToNumber :: FFIMap Int Number where
  ffi = toNumber

else instance rest :: FFIMap a a where
  ffi = identity

data CreateFFIRecord = CreateFFIRecord

instance createFFIRecord :: FFIMap a b => Mapping CreateFFIRecord a b where
  mapping CreateFFIRecord = ffi

The problem arises when trying to define one of these instances for one of these Node newtypes. I want to first send the record of fields (which should be a concrete record comprised of the fields accumulated via the hierarchy above) to a record with all the Maybes and Lists etc translated to Nullables and Iterators and so on, and then call an FFI function taking this record and producing an opaque type GraphObject_ (it’s the abstract class from which the entire hierarchy inherits):

-- FFI stuff
foreign import data GraphObject_ :: Type
foreign import newSmallNodeHorizontal :: forall r. Record (SmallPanelFields (SmallPartFields (SmallNodeFields r))) -> GraphObject_
-- ... other instances listed above go here
else instance nodeHorizontalToFFI :: RowToList r (Cons "isOpposite" Boolean Nil) => FFIMap (SmallNode "Horizontal" r) GraphObject_ where
  ffi (SN fields) = newSmallNodeHorizontal (hmap CreateFFIRecord fields)

The use of RowToList here is the only way I found of expressing a class behavior on a row: trying to define an instance directly on FFIMap (SmallNode "Horizontal" ( isOpposite :: Boolean) GraphObject_ is simply not supported.

The compiler complains with a pretty inscrutable error message:

No type class instance was found for

    Prim.RowList.RowToList ( avoidable :: Boolean
                           , isEnabled :: Boolean
                           , isLinkLabel :: Boolean
                           , itemIndex :: Number
                           , text :: String
                           , textEditable :: Boolean
                           | r3
                           )
                           t5


while solving type class constraint

  Heterogeneous.Mapping.HMap CreateFFIRecord
                             { avoidable :: Boolean
                             , isEnabled :: Boolean
                             , isLinkLabel :: Boolean
                             , itemIndex :: Number
                             , text :: String
                             , textEditable :: Boolean
                             | r3
                             }
                             { avoidable :: Boolean
                             , isEnabled :: Boolean
                             , isLinkLabel :: Boolean
                             , itemIndex :: Number
                             , text :: String
                             , textEditable :: Boolean
                             | t4
                             }

while applying a function hmap
  of type HMap t0 t1 t2 => t0 -> t1 -> t2
  to argument CreateFFIRecord
while inferring the type of hmap CreateFFIRecord
in value declaration nodeHorizontalToFFI

Now what I understand about this is that the heterogenous library’s constraints for my row variable r here are more permissive than I want them to be: the information that this row can ONLY be the row ( isOpposite :: Boolean ) does not reach the hmap instance for records.

So basically, this looks like a problem with the way I’m trying to express my solution: I want a newtype that given a symbol and a set of fields implements behaviors one way, and another symbol and another set of fields implements behaviors another way, but since it is extremely difficult to attach typeclass instances to “blobs of fields” (rows) without nasty tricks like the RowToList thing, I feel like I’m stretching the capabilities of row types, and am not sure if this is a doomed endeavor.

Sorry for the very long post. I’m quite invested in this project and just want this code to be really good.
(I tagged this with Halogen because I plan on using these bindings to use in a Halogen application, much like GoJS does in its examples)

I don’t have specific comments about modeling object oriented code, but you may also look at GitHub - natefaubion/purescript-convertable-options: Highly-overloaded APIs for PureScript which is sort of like heterogeneous specialized to the domain of overloaded record APIs, which appears to be what you’re doing. You could use this to straightforwardly write arbitrary, contextual field mappings. For example, you can have a field name in a particular call-context having specific conversions. There’s boilerplate, sure, but may give you more flexibility with better inference.

In general though you may think about changing your process from “I need to type raw FFI bindings in PureScript” to “What FFI do I need to support the ideal API I want from PureScript”. GoJS appears to have some tricky constraints, where even in it’s native language it may not be typed appropriately. Heavy OOP APIs often don’t translate super well to PureScript as-is. It is a pure FP language after all. It may help to think a little bit more holistically around what a nice, more native API might look like for PureScript, and then only write enough FFI to support that.

That’s going to be the basic problem. The compiler doesn’t support doing that with user-defined classes, and if you think you’ve confused the compiler into allowing it, you’ve confused the compiler too much for it to be helpful.

SmallNode strikes me as a bit of a boondoggle. What are you trying to achieve with that part of the design? Is it just a temporary wrapper for passing the properties that will ultimately be used to create a foreign Node object? Or are you hoping to handle those foreign objects after they’ve been created through the SmallNode interface, as if they’re simple records? There are better ways to do the former (see below), and the latter is probably not a great idea (assuming these are mutable objects, you’ll violate referential transparency by approximating them as immutable records).

If all you need is a type-safe constructor function parameterized by panel type, you can achieve that with something like this:

class ExtraNodeFields (panelType :: Symbol) (extraPanelFields :: Row Type) | panelType -> extraPanelFields
instance ExtraNodeFields "Horizontal" (isOpposite :: Boolean)
-- ... and more instances for more panel types

-- From glancing at the GoJS documentation, I think you'll want the node type as an argument, yeah?
foreign import newSmallNode :: forall r. String -> Record (SmallPanelFields (SmallPartFields (SmallNodeFields r))) -> GraphObject_

mkNode :: forall @s r r'.
  HMap CreateFFIRecord (Record r') (Record (SmallPanelFields (SmallPartFields (SmallNodeFields r)))) =>
  IsSymbol s =>
  ExtraNodeFields s r =>
  Record r' ->
    GraphObject_
mkNode args = newSmallNode (reflectSymbol @s Proxy) (hmap CreateFFIRecord args)
  -- reflectSymbol steals "Horizontal" out of the type system and into a string for you to use at run time

go :: GraphObject_
go = mkNode @"Horizontal"
  { isEnabled: true
  , itemIndex: 0 -- gets FFI'd to Number
  , text: ""
  , textEditable: false
  , avoidable: true
  , isLinkLabel: false
  , isOpposite: false
  }