DEV Community

Dwayne Crooks
Dwayne Crooks

Posted on

Decode and encode JSON in Haskell like an Elm developer

If you're an Elm developer writing Haskell and you like elm/json, you might like dwayne/hs-json-codec.

hs-json-codec provides JSON decoders and encoders for Haskell in the style of elm/json. It's built on dwayne/hs-json-parser, a general-purpose JSON parser and printer that I also wrote.

I built both libraries because I wanted them for dwayne/elm2nix, but they can be used in any Haskell project. In this post, I'll introduce their APIs and show how I use them in elm2nix.

Decoding JSON

Let's start with a small example. Given this Haskell type:

data User
  = User
      { name  :: Text
      , age   :: Int
      , email :: Maybe Text
      }
  deriving (Eq, Show)
Enter fullscreen mode Exit fullscreen mode

You can build a decoder by combining smaller decoders:

import qualified Json.Decode as JD

import Json.Decode (Decoder)


userDecoder :: Decoder User
userDecoder =
  User
    <$> JD.field "name" JD.text
    <*> JD.field "age" JD.integral
    <*> JD.field "email" (JD.nullable JD.text)
Enter fullscreen mode Exit fullscreen mode

If you've used Json.Decode in Elm, this should look familiar. A Decoder User knows how to convert a JSON value into a User, or describe why it could not.

You can run the decoder against Text:

JD.decodeText userDecoder "{\"name\": \"Douglas\", \"age\": 71, \"email\": null}"
-- Right (User {name = "Douglas", age = 71, email = Nothing})
Enter fullscreen mode Exit fullscreen mode

You can also run a decoder against an already-parsed JSON value or use one to decode a file:

decodeValue :: Decoder a -> Json -> Either DecodeError a
decodeFile :: Decoder a -> FilePath -> IO (Either Error a)
Enter fullscreen mode Exit fullscreen mode

The two error types are deliberate: decodeValue starts from an already-parsed JSON value, so only decoding can fail (DecodeError), while decodeFile has to parse first, so its Error also covers parse failures.

Decoding JSON in elm2nix

Decoding elm.json

elm2nix accepts one or more elm.json files and uses their dependencies to produce an elm.lock file. Here is the decoder it uses for an Elm application:

elmJsonDecoder :: JD.Decoder ElmJson
elmJsonDecoder =
  JD.field "type" (literal "application") >>
    ((\a b c d -> fromList $ a ++ b ++ c ++ d)
      <$> pathToDependenciesDecoder [ "dependencies", "direct" ]
      <*> pathToDependenciesDecoder [ "dependencies", "indirect" ]
      <*> pathToDependenciesDecoder [ "test-dependencies", "direct" ]
      <*> pathToDependenciesDecoder [ "test-dependencies", "indirect" ])
Enter fullscreen mode Exit fullscreen mode

The structure is very similar to the decoders I would write in Elm. It first checks that type is "application", then decodes the four collections of dependencies and combines them.

pathToDependenciesDecoder handles paths that may be absent:

pathToDependenciesDecoder :: [Text] -> JD.Decoder [Dependency]
pathToDependenciesDecoder path =
  fmap (fromMaybe []) (JD.optionalAt path dependenciesDecoder)
Enter fullscreen mode Exit fullscreen mode

Finally, dependenciesDecoder converts the keys of a JSON object into package names and its values into versions:

dependenciesDecoder :: JD.Decoder [Dependency]
dependenciesDecoder =
  JD.keyValuePairsWithKeyTransformer toName JD.decoder >>= JD.succeed . map (uncurry Dependency)
Enter fullscreen mode Exit fullscreen mode

(JD.decoder here is Version's canonical decoder — more on FromJson in the next section.)

See the complete ElmJson decoder.

The decoder is used by fromFile:

fromFile :: FilePath -> IO (Either JD.Error ElmJson)
fromFile = JD.decodeFile elmJsonDecoder
Enter fullscreen mode Exit fullscreen mode

That function is then used by ElmJson.fromFiles, which decodes every supplied elm.json file and combines their dependencies.

Decoding elm.lock

elm2nix uses the same approach to decode its generated lock file. elmLockDecoder combines list, field, text, and custom version decoders:

elmLockDecoder :: JD.Decoder ElmLock
elmLockDecoder = fromList <$> JD.list dependencyDecoder


dependencyDecoder :: JD.Decoder Dependency
dependencyDecoder = Dependency <$> nameDecoder <*> versionDecoder
Enter fullscreen mode Exit fullscreen mode

One thing I wanted was the ability to give a type its canonical decoder. FromJson provides that:

class FromJson a where
  decoder :: Decoder a
Enter fullscreen mode Exit fullscreen mode

For example, Version implements FromJson, so other decoders can use JD.decoder without needing to know how a version is represented in JSON. Its custom decoder still has the familiar shape of decoding a primitive value and then validating it with succeed or fail.

Encoding JSON

Encoders are functions from Haskell values to JSON values:

encodeUser :: User -> Json
encodeUser (User name age email) =
  JE.object
    [ ( "name", JE.text name )
    , ( "age", JE.int age )
    , ( "email", JE.nullable JE.text email )
    ]
Enter fullscreen mode Exit fullscreen mode

Again, this follows the way Json.Encode works in Elm.

ToJson lets a type provide its canonical encoding:

class ToJson a where
  encode :: a -> Json
Enter fullscreen mode Exit fullscreen mode

Encoding JSON in elm2nix

elm2nix uses ToJson to encode each fixed-output derivation written to elm.lock:

instance ToJson FixedOutputDerivation where
  encode (FixedOutputDerivation (Dependency name version) hash _) =
    JE.object
      [ ( "author", encode $ Name.toAuthor name )
      , ( "package", encode $ Name.toPackage name )
      , ( "version", encode $ T.show version )
      , ( "sha256", encode $ T.pack hash )
      ]
Enter fullscreen mode Exit fullscreen mode

See the source.

The list of derivations is then encoded and written in either compact or pretty-printed form by encodeCompact and encodeExpanded.

elm2nix also uses both libraries to convert Elm's binary registry.dat format into compact or pretty-printed JSON. See viewRegistryDatFile.

Parsing and printing JSON

You can also use hs-json-parser directly when you only need to parse or print JSON.

To make sure hs-json-parser handles JSON correctly, I tested it against nst/JSONTestSuite. It is compliant with RFC 8259.

Json.parse "[true, false, true]"
-- Right (Array [ Boolean True, Boolean False, Boolean True ])
Enter fullscreen mode Exit fullscreen mode

Once you have a JSON value, you can print it in either compact or pretty-printed form:

import qualified Data.Text.IO as TIO

value = Array [ Boolean True, Boolean False, Boolean True ]

TIO.putStrLn $ Json.compact value
-- [true,false,true]

TIO.putStrLn $ Json.pretty 4 value
-- [
--     true,
--     false,
--     true
-- ]
Enter fullscreen mode Exit fullscreen mode

What about performance?

I focused on the API, correctness, and user experience rather than optimizing performance. I haven't benchmarked these libraries against the established Haskell JSON libraries.

However, I haven't encountered any performance problems while using them in elm2nix, so they are at least good enough for that project.

If performance is critical to your application, you should benchmark them against your workload before choosing them.

Try them

Both libraries are currently distributed through their Git repositories rather than Hackage. Their READMEs explain how to add them to a Cabal project:

If you're an Elm developer writing Haskell, consider giving hs-json-codec a try. I hope it makes working with JSON in Haskell feel a little more familiar.

Enjoy!

Top comments (0)