3

I have a data type like this:

data A = A T.Text deriving (Generic, Show)

instance A.ToJSON A 

If I use A.encode to it:

A.encode $ A "foobar" -- "foobar"

Then I use singleTagConstructors on it:

instance A.ToJSON A where
  toEncoding a = A.genericToEncoding $ A.defaultOptions { A.tagSingleConstructors = True }

A.encode $ A "foobarquux" -- "{tag: A, contents: foobarquux}"

At some point I made another data type:

newtype Wrapper a = Wrapper 
  { unWrap :: a
  } deriving (Show)

instance A.ToJSON a => A.ToJSON (Wrapper a) where 
  toJSON w = A.object [ "wrapped" A..= unWrap w ]

Here's the part where I get confused:

A.encode $ Wrapper $ A "foobar" -- "{wrapped: foobar}"

How do I get the result to be like this?

"{wrapped: {tag: A, contents: foobarquux}}"
autumn322
  • 447
  • 3
  • 10

2 Answers2

3

To answer the question directly, you can always implement the Wrapper instance with tagSingleConstructors = False, like this:

instance Generic a => A.ToJSON (Wrapper a) where 
  toJSON w = A.object [ "wrapped" A..= encA (unWrap w) ]
    where
      encA = A.genericToEncoding $ A.defaultOptions { A.tagSingleConstructors = False }

But I don't see why you'd want to do that.

If you control the API, then you don't need the tag field: the expected type of the wrapped value is already statically known, so tag would not be helpful.

And if you don't control the API, I would recommend representing it very explicitly, for example as a record that exactly matches the API shape. Otherwise you run a risk of accidentally breaking the API by making unrelated changes in remote parts of the codebase.

Fyodor Soikin
  • 78,590
  • 9
  • 125
  • 172
  • I was making an http api and sending back error response constructed from sum types encoded as json with `tag`. It was nice that I could see what was returned because of the `tag`. So to be consistent I decided to make a non-sum types to be tagged too. But thinking about it maybe I'm exposing too much of its internal by doing that. – autumn322 Aug 24 '19 at 00:12
3

The issue is how you implemented your custom ToJSON instance.

instance A.ToJSON A where
  toEncoding a = A.genericToEncoding $ A.defaultOptions { A.tagSingleConstructors = True }

Since you do not implement toJSON directly default implementation from typeclass definition is used.

class ToJSON a where -- excerpt from Data.Aeson.Types.ToJSON
    -- | Convert a Haskell value to a JSON-friendly intermediate type.
    toJSON     :: a -> Value

    default toJSON :: (Generic a, GToJSON' Value Zero (Rep a)) => a -> Value
    toJSON = genericToJSON defaultOptions

Effectively, you have the following instance:

instance A.ToJSON A where
  toJSON = genericToJSON defaultOptions
  toEncoding a = A.genericToEncoding $ A.defaultOptions { A.tagSingleConstructors = True }

While toEncoding uses expected tagged encoding, toJSON uses default encoding (which does not tag single constructors). This inconsistency is the root cause of the confusion. Later, in wrapper's ToJSON instance .= operator used. Internally, it uses toJSON and not toEncoding:

class KeyValue kv where
    (.=) :: ToJSON v => Text -> v -> kv
    infixr 8 .=

instance KeyValue Pair where
    name .= value = (name, toJSON value)

As a solution, you should either define only toJSON and keep default toEncoding implementation (that uses toJSON) or implement both.

kfigiela
  • 31
  • 2