Magic JSON in Haskell

Among all Haskell libraries I have used, the one I reach for the most is autodocodec. I will explain what it is and what freebies it gives you.

I have been thinking about writing this post for a while, but I never got around to it. There is no right time to talk about it, so it is now.

JSON in Haskell

It is almost too simple. aeson library, the most popular JSON library in Haskell, defines a JSON value as:

data Value
  = Object !Object
  | Array !Array
  | String !Text
  | Number !Scientific
  | Bool !Bool
  | Null

Haskell data types can have JSON value representations:

data Post = MkPost
  { postTitle :: Text
  , postContent :: Text
  , postPublished :: Bool
  }
  deriving (Show)


instance ToJSON Post where
  toJSON (MkPost title content published) =
    object
      [ "title" .= title
      , "content" .= content
      , "published" .= published
      ]


instance FromJSON Post where
  parseJSON = withObject "Post" $ \o ->
    MkPost
      <$> o .: "title"
      <*> o .: "content"
      <*> o .: "published"

This way, we can work with the JSON representation of our data types:

>>> encode (MkPost "Hello, World!" "This is a very short post." True)
{"content":"This is a very short post.","published":true,"title":"Hello, World!"}
>>> decode "{\"content\":\"This is a very short post.\",\"published\":true,\"title\":\"Hello, World!\"}" :: Maybe Post
Just (MkPost {postTitle = "Hello, World!", postContent = "This is a very short post.", postPublished = True})

If you are coming from JavaScript or TypeScript, this is what you are used to do with JSON.parse and zod. And you are right, in Haskell, this is quite verbose and boilerplate-driven. But it is quite principled and type-safe. For example, you can parse and map, but cannot perform IO operations when defining your ToJSON and FromJSON instances. Also, you cannot have multiple JSON representations of the same data type (so-called type-class instance coherence in Haskell). You need newtype wrappers for that:

newtype QuickPost = MkQuickPost Post
  deriving (Show)


instance ToJSON QuickPost where
  toJSON (MkQuickPost (MkPost title content _)) =
    object
      [ "subject" .= title
      , "body" .= content
      , "published" .= True
      ]


instance FromJSON QuickPost where
  parseJSON = withObject "QuickPost" $ \o ->
    MkQuickPost
      <$> (MkPost
              <$> o .: "subject"
              <*> o .: "body"
              <*> o .: "published"
          )

We are referring to the same data type, but with a different JSON representation, without any runtime overhead:

>>> encode (MkQuickPost (MkPost "Hello, World!" "This is a very short post." True))
{"body":"This is a very short post.","published":true,"subject":"Hello, World!"}
>>> decode "{\"body\":\"This is a very short post.\",\"published\":true,\"subject\":\"Hello, World!\"}" :: Maybe QuickPost
Just (MkQuickPost (MkPost {postTitle = "Hello, World!", postContent = "This is a very short post.", postPublished = True}))

JSON Field Order

You might have noticed that the order of fields in the JSON output is not the same as the order of fields in the Haskell data type. This is because aeson does not guarantee the order of fields in the JSON output if you define ToJSON without toEncoding. toEncoding would be a more efficient and predictable way to define how we output the JSON representation. If you do not define it, aeson will use toJSON to convert the data type to a JSON Value, and use Value’s toEncoding to output the JSON. This is when the order of fields is based on what is pulled from the Map in the Object constructor, which is not necessarily the same as the order of fields in the Haskell data type.

Let us try to define toEncoding for our Post data type:

instance ToJSON Post where
  toJSON (MkPost title content published) =
    object
      [ "title" .= title
      , "content" .= content
      , "published" .= published
      ]

  toEncoding (MkPost title content published) =
    pairs
      (  "title" .= title
      <> "content" .= content
      <> "published" .= published
      )

And the result is:

>>> encode (MkPost "Hello, World!" "This is a very short post." True)
{"title":"Hello, World!","content":"This is a very short post.","published":true}

What if I am lazy?

If you are lazy enough, you will not want to define ToJSON and FromJSON instances for your data types manually. You will want to use Generic or TemplateHaskell to derive them automatically.

I have used the Generic approach for a very long time. It is quite simple and you might even want to centralize your JSON style to enforce it across your data types:

data Post = MkPost
  { postTitle :: Text
  , postContent :: Text
  , postPublished :: Bool
  }
  deriving (Show, Generic, Eq)


instance ToJSON Post where
  toEncoding = genericToEncoding defaultOptions {
    fieldLabelModifier = camelTo2 '_' . drop 4
  }


instance FromJSON Post where
  parseJSON = genericParseJSON defaultOptions {
    fieldLabelModifier = camelTo2 '_' . drop 4
  }

… which will give us:

>>> encode (MkPost "Hello, World!" "This is a very short post." True)
{"title":"Hello, World!","content":"This is a very short post.","published":true}
>>> decode "{\"content\":\"This is a very short post.\",\"published\":true,\"title\":\"Hello, World!\"}" :: Maybe Post
Just (MkPost {postTitle = "Hello, World!", postContent = "This is a very short post.", postPublished = True})

Do not worry much about the fieldLabelModifier function. You can create your own options generator that would work for all your data types as long as you tell it how to drop the prefix from the field names:

myOptions :: String -> Options
myOptions prefix = defaultOptions {
  fieldLabelModifier = camelTo2 '_' . drop (length prefix)
}

… then:

instance ToJSON Post where
  toEncoding = genericToEncoding (myOptions "post")

instance FromJSON Post where
  parseJSON = genericParseJSON (myOptions "post")

What if I am even lazier?

OK, if you have not used Haskell before, by now, you might have already given up. But this approach of defining type-class instances is quite common in Haskell. They often involve boilerplate, but they provide significant safety and discipline.

What if we want to generate the YAML representations? How about JSON schema or OpenAPI schema?

They all have their own type-classes (ToSchema) or piggyback on the existing ones (for example, yaml uses aeson for decoding and encoding).

This is where autodocodec comes in. It is a library that allows you to automatically derive instances such as ToJSON, FromJSON, ToSchema and FromSchema. You will write the boilerplate once, and then you will be able to generate the rest of the instances automatically.

Let us reproduce the previous example using autodocodec:

data Post = MkPost
  { postTitle :: Text
  , postContent :: Text
  , postPublished :: Bool
  }
  deriving (Show, Generic, Eq)
  deriving (FromJSON, ToJSON) via (Autodocodec Post)


instance HasCodec Post where
  codec =
    object "Post" $
      MkPost
        <$> requiredField "title" "Title of the post" .= postTitle
        <*> requiredField "content" "Content of the post" .= postContent
        <*> requiredField "published" "Publish status of the post" .= postPublished

… and:

>>> encode (MkPost "Hello, World!" "This is a very short post." True)
{"title":"Hello, World!","content":"This is a very short post.","published":true}
>>> decode "{\"content\":\"This is a very short post.\",\"published\":true,\"title\":\"Hello, World!\"}" :: Maybe Post
Just (MkPost {postTitle = "Hello, World!", postContent = "This is a very short post.", postPublished = True})

We wrote more, but did we get more? Yes, we did. Look at this:

>>> Data.Text.IO.putStrLn $ renderColouredSchemaViaCodec @Post
# Post
title: # required
  # Title of the post
  <string>
content: # required
  # Content of the post
  <string>
published: # required
  # Publish status of the post
  <boolean>

With the help of renderColouredSchemaViaCodec function from autodocodec-yaml, we can generate a coloured schema for our data type. Cool, right?

Let us do more, with the help of jsonSchemaViaCodec function from autodocodec-schema:

>>> encode $ jsonSchemaViaCodec @Post
{
  "$comment": "Post",
  "properties": {
    "content": {
      "$comment": "Content of the post",
      "type": "string"
    },
    "published": {
      "$comment": "Publish status of the post",
      "type": "boolean"
    },
    "title": {
      "$comment": "Title of the post",
      "type": "string"
    }
  },
  "required": [
    "published",
    "content",
    "title"
  ],
  "type": "object"
}

Conclusion

I really like autodocodec. I am using it heavily in my projects, especially when I need to generate JSON Schema (for example, to aid configuration file specifications) or OpenAPIv3 Schema (for Servant projects). I recently noticed that there is even autodocodec-nix as an interpreter for Nix expressions.

I am fine with writing boilerplate as long as it gives me type-safety and discipline. Using autodocodec, I feel like maximizing my return on investment into writing boilerplate.

Published on 23 May 2025 Technical Notes Hacking Haskell