Deriving Aeson type classes with two different possible types in the same field

408 views Asked by At

I have an API which returns JSON results in the following form:

{
  "data": [1, 2, 3]
}

The data field can be the encoding of two distinct records which are shown below:

newtype ResultsTypeA = ResultsTypeA [ResultTypeA]
newtype ResultsTypeB = ResultsTypeB [ResultTypeB]

When I query this API from Haskell, I know in advance whether I'm dealing with a ResultsTypeA or a ResultsTypeB because I'm explicitly asking for it in the query.

The part where I'm struggling is with the Aeson ToJSON and FromJSON instances. Since both result types A and B are ultimately lists of Int, I can't use pattern matcher in FromJSON, because I could only match a [Int] in both cases.

This is why I thought of doing the following:

newType ApiResponse a =
    ApiResponse {
        data :: a
    }

newtype ResultsTypeA = ResultsTypeA [ResultTypeA]
newtype ResultsTypeB = ResultsTypeB [ResultTypeB]

However I can't get my head around how to write the ToJSON and FromJSON instances for the above, because now ApiResponse has a type parameter, and nowhere in Aeson docs seem to be a place where it is explained how to derive these instances with a type parameter involved.

Another alternative, avoiding a type parameter, would be the following:

newtype Results =
    ResultsTypeA [ResultTypeA]
  | ResultsTypeB [ResultTypeB]

newtype ApiResponse =
    ApiResponse {
        data :: Results
    }

In this case the ToJSON is straightforward:

instance ToJSON ApiResponse where
    toJSON = genericToJSON $ defaultOptions

But the FromJSON gets us back to the problem of not being able to decide between result types A and B...

It is also possible that I'm doing it wrong entirely and there is a third option I wouldn't see.

  • how would the FromJSON / ToJSON instances look like with a type parameter on ApiResponse?
  • is there a better alternative completely different from anything exposed above to address this?
1

There are 1 answers

0
danidiaz On

Since both result types A and B are ultimately lists of Int, I can't use pattern matcher in FromJSON, because I could only match a [Int] in both cases.

If you have a parameterized type, and you are writing a FromJSON instance by hand, you can put the precondition that the parameter must itself have a FromJSON instance.

Then, when you are writing the parser, you can use the parser for the type parameter as part of your definition. Like this:

{-# LANGUAGE OverloadedStrings #-}
import Data.Aeson

data ApiResponse a =
    ApiResponse {
        _data :: a,
        other :: Bool
    } 

instance FromJSON a => FromJSON (ApiResponse a) where
    parseJSON = withObject "" $ \o -> 
          ApiResponse <$> o .: "data" -- we are using the parameter's FromJSON 
                      <*> o .: "other"

Now, let's define two newtypes that borrow their respective FromJSON instances from that of Int, using GeneralizedNewtypeDeriving:

{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE DerivingStrategies #-}
-- Make the instances for the newtypes exactly equal to that of Int
newtype ResultTypeA = ResultTypeA Int deriving newtype FromJSON
newtype ResultTypeB = ResultTypeB Int deriving newtype FromJSON

If we load the file in ghci, we can supply the type parameter to ApiResponse and interrogate the available instances:

ghci> :instances ApiResponse [ResultTypeA]
instance FromJSON (ApiResponse [ResultTypeA])

You can also auto-derive FromJSON for ApiResponse, if you also derive Generic:

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DerivingStrategies #-}
import Data.Aeson
import GHC.Generics

data ApiResponse a =
    ApiResponse {
        _data :: a,
        other :: Bool
    } 
    deriving stock Generic
    deriving anyclass FromJSON

deriving stock Generic makes GHC generate a representation of the datatype's structure that can be used to derive implementations for other typeclasses—here, FromJSON. For those derivations to be made through the Generic machinery, they need to use the anyclass method.

The generated instance will be of the form FromJSON a => FromJSON (ApiResponse a), just like the hand-written one. We can check it again in ghci:

ghci> :set -XPartialTypeSignatures
ghci> :set -Wno-partial-type-signatures
ghci> :instances ApiResponse _
instance FromJSON w => FromJSON (ApiResponse w)
instance Generic (ApiResponse w)