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)