I wanted a small JSON endpoint for a tiny Haskell program. The sensible answer was to use a JSON library. The sensible answer was also boring, so I looked at the Show typeclass and decided it was probably close enough.
This is not a recommendation. It is more like a reconstruction of a bad decision.
The First Mistake
Here is the sort of type Haskell is happy to print for us:
data Creature = Creature
{ name :: String
, age :: Int
, cute :: Bool
, secret :: Maybe String
} deriving ShowThe derived instance gives us this:
Creature {name = "Axolotl", age = 3, cute = True, secret = Nothing}It has a constructor name, unquoted keys, capitalised booleans, and a Nothing where JSON expects null. It is not JSON. It is Haskell trying to look helpful.
The obvious approach is to replace a few words. True becomes true. Nothing becomes null. Maybe braces can be shuffled around a bit.
That approach has two problems. The first is that it does not work. The second is that it gives you a parser-shaped problem before you have written any parser.
Making Show Lie
Instead of parsing the output of show, I can write a different Show instance. This is worse in a more direct way.
import Data.List (intercalate)
data Creature = Creature
{ name :: String
, age :: Int
, cute :: Bool
, secret :: Maybe String
}
instance Show Creature where
show (Creature creatureName creatureAge isCute creatureSecret) =
jsonObject
[ ("name", jsonString creatureName)
, ("age", show creatureAge)
, ("cute", jsonBool isCute)
, ("secret", jsonMaybe jsonString creatureSecret)
]Now show is secretly an encoder. The type still says nothing about JSON, and the function still returns an ordinary String, but the output looks much more convincing:
show (Creature "Axolotl" 3 True Nothing)
{"name":"Axolotl","age":3,"cute":true,"secret":null}The object helper is not complicated. That is part of the trap.
jsonObject :: [(String, String)] -> String
jsonObject fields =
"{" ++ intercalate "," (map renderField fields) ++ "}"
where
renderField (key, value) = jsonString key ++ ":" ++ value
jsonBool :: Bool -> String
jsonBool True = "true"
jsonBool False = "false"
jsonMaybe :: (a -> String) -> Maybe a -> String
jsonMaybe _ Nothing = "null"
jsonMaybe encode (Just x) = encode xAt this point the program has crossed an important line. show creature is no longer useful as a debugging representation. If I print a creature while investigating a bug, I get something that looks like a wire format. If I change the JSON field names, I also change my logs.
This is why global instances are fun right up until somebody has to maintain them.
The String Problem
There is still one unpleasant detail. Strings need escaping. A name containing a quote should not be allowed to close the JSON string early.
jsonString :: String -> String
jsonString value = '"' : concatMap escape value ++ "\""
where
escape '"' = "\\\""
escape '\\' = "\\\\"
escape '\n' = "\\n"
escape '\r' = "\\r"
escape '\t' = "\\t"
escape c = [c]This handles the obvious cases, which is not the same thing as handling strings correctly. JSON has rules for control characters and Unicode escapes too. The little function above is already more serious than the original idea of doing a few replacements, but it is still a homemade serialization format hiding inside Show.
Try a name with a quote:
show (Creature "Dr. \"Bones\"" 42 False (Just "secret"))
{"name":"Dr. \"Bones\"","age":42,"cute":false,"secret":"secret"}At least the quote stays inside the value. That feels like a success until you remember that the entire point of this exercise was to avoid using the thing that already solves this problem.
It Gets Worse With Lists
One object is manageable. Arrays add another helper and another place to accidentally produce valid-looking nonsense.
jsonArray :: (a -> String) -> [a] -> String
jsonArray encode values =
"[" ++ intercalate "," (map encode values) ++ "]"We can add a list of names to the creature, then thread that through the hand-written instance. Every new field means more code in a place that was supposed to be a harmless debugging instance.
There is also no general Show a => a -> JSON function hiding around the corner. Show gives us text, not structure. Once the structure has been flattened into text, we have to guess where the values end, whether a quote was escaped, and whether True appeared inside a string.
The type system cannot help much after we have thrown the type away.
Why This Is a Bad Idea
This small example has several problems that a real JSON library has already spent years dealing with:
Showis meant for human-readable output, not a stable API.- The output type is
String, so there is no distinction between valid JSON and almost JSON. - Every domain type needs a carefully maintained instance.
- Escaping is easy to get nearly right.
- A change intended for logging can break an HTTP client.
- There is no decoder, schema, validation, or useful error message.
The code is not useless. It shows exactly what a typeclass method is doing. An instance is just the implementation selected for a particular type, and there is nothing stopping that implementation from being a terrible idea.
It also shows why the typeclass you choose matters. Show communicates “turn this into a readable Haskell representation.” A JSON typeclass communicates “turn this into a JSON value.” Those are different jobs, even if both eventually produce text.
The proper version would build a JSON value first and render it later. The even more proper version would use aeson, derive the instances, and spend the afternoon doing something else.
I chose Show because I wanted to see how far I could push a debugging feature before it became a serialization library. The answer is about three helper functions past the point where I should have stopped.