Search code examples
jsonpurescriptrow-polymorphism

How to parse row-polymorphic records with SimpleJSON in PureScript?


I wrote a utility type and function that is meant to aid in parsing certain row-polymorphic types (sepcifically, in my case, anything that extends BaseIdRows:

type IdTypePairF r = (identifier :: Foreign, identifierType :: Foreign | r)


readIdTypePair :: forall r. Record (IdTypePairF r) -> F Identifier
readIdTypePair idPairF = do
  id <- readNEStringImpl idPairF.identifier
  idType <- readNEStringImpl idPairF.identifierType
  pure $ {identifier: id, identifierType: idType}

When I try to use it, however, it causes the code to get this type error (in my larger code base, things were working fine before I implemented the readIdTypePair function):

  No type class instance was found for

    Prim.RowList.RowToList ( identifier :: Foreign
                           , identifierType :: Foreign
                           | t3
                           )
                           t4

  The instance head contains unknown type variables. Consider adding a type annotation.

while applying a function readJSON'
  of type ReadForeign t2 => String -> ExceptT (NonEmptyList ForeignError) Identity t2
  to argument jsStr
while checking that expression readJSON' jsStr
  has type t0 t1
in value declaration readRecordJSON

where t0 is an unknown type
      t1 is an unknown type
      t2 is an unknown type
      t3 is an unknown type
      t4 is an unknown type

I have a live gist that demonstrates my issue.

But, here is the complete example as it stands, for posterity:

module Main where

import Control.Monad.Except (except, runExcept)
import Data.Array.NonEmpty (NonEmptyArray, fromArray)
import Data.Either (Either(..))
import Data.HeytingAlgebra ((&&), (||))
import Data.Lazy (Lazy, force)
import Data.Maybe (Maybe(..))
import Data.Semigroup ((<>))
import Data.String.NonEmpty (NonEmptyString, fromString)
import Data.Traversable (traverse)
import Effect (Effect(..))
import Foreign (F, Foreign, isNull, isUndefined)
import Foreign as Foreign
import Prelude (Unit, bind, pure, ($), (>>=), unit)
import Simple.JSON as JSON

main :: Effect Unit
main = pure unit


type ResourceRows = (
  identifiers :: Array Identifier
)
type Resource = Record ResourceRows

type BaseIdRows r = (
  identifier :: NonEmptyString
, identifierType :: NonEmptyString
| r
)
type Identifier = Record (BaseIdRows())

-- Utility type for parsing
type IdTypePairF r = (identifier :: Foreign, identifierType :: Foreign | r)



readNEStringImpl :: Foreign -> F NonEmptyString
readNEStringImpl f = do
  str :: String <- JSON.readImpl f
  except $ case fromString str of
    Just nes -> Right nes
    Nothing -> Left $ pure $ Foreign.ForeignError
      "Nonempty string expected."

readIdTypePair :: forall r. Record (IdTypePairF r) -> F Identifier
readIdTypePair idPairF = do
  id <- readNEStringImpl idPairF.identifier
  idType <- readNEStringImpl idPairF.identifierType
  pure $ {identifier: id, identifierType: idType}

readRecordJSON :: String -> Either Foreign.MultipleErrors Resource
readRecordJSON jsStr = runExcept do
  recBase <- JSON.readJSON' jsStr
  --foo :: String <- recBase.identifiers -- Just comment to check inferred type
  idents :: Array Identifier <- traverse readIdTypePair recBase.identifiers
  pure $ recBase { identifiers = idents }

Solution

  • Your problem is that recBase is not necessarily of type Resource.

    The compiler has two points of reference for determining the type of recBase: (1) the fact that recBase.identifiers is used with readIdTypePair and (2) the return type of readRecordJSON.

    From the first point the compiler can conclude that:

    recBase :: { identifiers :: Array (Record (IdTypePair r)) | p }
    

    for some unknown r and p. The fact that it has (at least) a field named identifiers comes from the dot-syntax, and the type of that field comes from readIdTypePair's parameter combined with the fact that idents is an Array. But there could be more fields besides identifiers (which is represented by p), and every element of identifiers is a partial record (which is represented by r).

    From the second point the compiler can conclude that:

    recBase :: { identifiers :: a }
    

    Wait, what? Why a and not Array Identifier? Doesn't the definition of Resource clearly specify that identifiers :: Array Identifier?

    Well, yes, it does, but here's the trick: the type of recBase doesn't have to be Resource. The return type of readRecordJSON is Resource, but between recBase and return type of readRecordJSON stands a record update operation recBase { identifiers = idents }, which can change the type of the field.

    Yes, record updates in PureScript are plymorphic. Check this out:

    > x = { a: 42 }
    > y = x { a = "foo" }
    > y
    { a: "foo" }
    

    See how the type of x.a changed? Here x :: { a :: Int }, but y :: { a :: String }

    And so it is in your code: recBase.identifiers :: Array (IdTypePairF r) for some unknown r, but (recBase { identifiers = idents }).identifiers :: Array Identifier

    The return type of readRecordJSON is satisfied, but the row r is still unknown.


    To fix, you have two options. Option 1 - make readIdTypePair take a full record, not a partial one:

    readIdTypePair :: Record (IdTypePairF ()) -> F Identifier
    

    Option 2 - specify the type of recBase explicitly:

    recBase :: { identifiers :: Array (Record (IdTypePairF ())) } <- JSON.readJSON' jsStr
    

    Separately, I feel the need to comment on your weird way of specifying records: you first declare a row and then make a record out of it. FYI it can be done directly with curly braces, for example:

    type Resource = {
      identifiers :: Array Identifier
    }
    

    In case you're doing it this way for aesthetic reasons, I have no objections. But in case you didn't know - now you know :-)