
Arthur Jamet's 25 September post parses JSON straight into a record whose fields start out undefined and finishes the job with a bit-set counter, skipping the JSON tree entirely and running about three times faster in his own benchmark.
undefined on purpose, and lets the parser fill them in as it walks past. On the author's own benchmark the result is about three times faster than the same code going through a JSON tree, and it still reports cleanly when a required field never arrives.data JSON
= Number Number
| Bool Bool
| List [JSON]
| Null Null
| String String
| Object ObjectderiveFromJSON or aeson's generic instances:data Album = Album
{ title :: T.Text
, artistName :: Maybe T.Text
, songCount :: Int
}
fromJSON :: JSON -> Maybe Album
fromJSON = \case
Object obj -> do
title <- obj !? "title"
artistName <- obj !? "artist_name"
songCount <- obj !? "song_count"
return Album{..}
_ -> NothingfromJSON reads easily and tests easily. 1Album — and accept that the value is broken for most of the parse. Laziness makes that expressible. The record can start with its fields left as undefined, or be built with no fields filled in at all, which GHC accepts while warning about the fields left out. Nothing forces those fields while the parser is still working, so nothing crashes. 1undefined to find out is the one thing that must not happen, so the post threads a second value alongside the record: a bit set, one bit per field, starting at maxBound and clearing a bit each time the matching field is set.parseAlbum :: Parser Album
parseAlbum = case runParser (go Album{} maxBound) of
(a, 0) -> return a
(_, bits) -> fail ...
where
go :: Album -> BitSet -> Parser (Album, BitSet)go'' :: Album -> BitSet -> Parser (Album, BitSet)
go'' album bitset = do
fieldName <- parseString
char ':'
case fieldName of
"title" -> do
title <- parseString
return (album{title = title}, bitset `clearBit` 0)
"artist_name" -> do
artistName <- parseString
return (album{artistName = artistName}, bitset `clearBit` 1)
"song_count" -> do
songCount <- parseInt
return (album{songCount = songCount}, bitset `clearBit` 2)Maybe, the parser writes Nothing into them and returns the value as complete. 1StrictData extension makes constructor fields strict by default for a whole module, which would force the undefined fields during the parse and defeat the approach. 2 And the parser keeps its completion state on the side rather than inside the record, which is where the returned (Album, BitSet) pair comes from.
go'' holds one branch per field, and each branch names a field, a type and a bit position. That structure follows directly from the data type, which is why Jamet generates it with Template Haskell, GHC's facility for compile-time meta-programming, instead of writing it out by hand. 3 The parsing engine underneath is flatparse, a high-performance parser over strict bytestrings, and the implementation sits in a package that describes itself as compile-time generated de/serialisers in Haskell. 45State monad to assemble those ASTs, and he explains the reasoning in a February 2026 post. 6Criterion benchmarking harness on one machine: two Xeon Gold 6244 CPUs at 3.60 GHz, 32 GB of RAM, Ubuntu 22.04. 1| Library / object | Book | Author with a list of books |
|---|---|---|
| aeson (with an intermediate representation) | 1.051 µs | 2.901 µs |
| Jamet's library (with an intermediate representation) | 922.7 ns | 2.813 µs |
| Jamet's library (without one) | 314.2 ns | 1.027 µs |
fromJSON, so its gap to the third row isolates one thing: the cost of the intermediate representation. That gap is the roughly three-fold difference he reports. The gap to aeson in the first row measures something else, because aeson is different software with options the prototype never implements. 7Album on a single machine, and the author says they may not reflect performance in real applications. 1Maybe sweep answers the optional version of the same question. The third case the post names — fields present in the input but absent from the data type — falls to the same name matching, where each member's name is read and only the known names lead anywhere. 1 Everything else about the parse is the parser's ordinary business.undefined. Rust's serde already does the compile-time version of the same move, generating its implementations with a derive macro instead of consulting a runtime data model. 18"In this post, I will show a way to parse JSON objects without using an IR, by using partially-initialised data.""I know, this sounds horrible and unbearably unsafe. But I believe this approach can help the compiler during its optimisation pass.""The problem is thatPartialAlbumis an IR, so we want to avoid that."— Arthur Jamet, Parsing JSON Objects without intermediate ASTs
"The goal of this post is not to shame libraries likeaesonfor relying on IRs. IRs are useful and allow decoupling the parsing process from the validation step. Our approach merges both, and makes the code more complex and less maintainable but significantly faster."— Arthur Jamet, Parsing JSON Objects without intermediate ASTs
arthi-chaud.github.io
ghc.gitlab.haskell.org
ghc.gitlab.haskell.org
hackage.haskell.org
arthi-chaud.github.io
hackage.haskell.org
serde.rs
This story was produced automatically by a channel. One sentence is all it takes for Neodrop to keep producing for you.