A lot of web development is transforming JSON one way or another. In TypeScript/JavaScript, this is straightforward, since JSON is built into the language. But can we also achieve good ergonomics in Haskell and Rust? Dear reader, I am glad you asked! 🙌

The comparisons we will see are not meant to show if one approach is better than another. Instead, it is intended to be a reference to become familiar with common patterns across multiple languages. Throughout this post, we will utilize several tools and libraries.

The core of working with JSON in Haskell and Rust is covered by:

Aeson: a Haskell JSON serialization/deserialization library .

Serde: a Rust JSON serialization/deserialization library.

The ergonomics is then improved in Haskell by grabbing one of the following options :

Lens: a heavy-weight library to transform and work with records (and much more!) .

Record Dot Syntax: an upcoming language extension in Haskell, which recently got accepted by the GHC steering Committee .

We’ll go through typical use-cases seen in TypeScript/JavaScript codebases, and see how we can achieve the same in Haskell and Rust.

Table of Contents:

Preparation: Setting up our data

First, we will set up our data structures and a few examples, which we will use throughout this post. Haskell and Rust require a bit more ceremony because we will use packages/crates. For TypeScript we use ts-node to run TypeScript in a REPL.

TypeScript

Let us first set up our reference Object in TypeScript. Save the following in house.ts (or check out typescript-json):

interface Address { : string ; country : string ; address } interface Person { : number ; id : string ; firstname : string ; lastname } interface Household { : number ; id : Person[] ; peoplePerson[] ?: Address ; addressAddress ?: Address ; alternativeAddressAddress : Person ; ownerPerson } : Address = { country : "Ocean" , address : "Under the sea" } ; const addrAddress{ countryaddress : Person = { id : 1 , firstname : "Ariel" , lastname : "Swanson" } ; const momPerson{ idfirstnamelastname : Person = { id : 2 , firstname : "Triton" , lastname : "Swanson" } ; const dadPerson{ idfirstnamelastname : Person = { id : 3 , firstname : "Eric" , lastname : "Swanson" } ; const sonPerson{ idfirstnamelastname : Household = { const houseHousehold : 1 , id : [mom , dad , son] , people[momdadson] : addr , addressaddr // We omit `alternativeAddress` which is optional. : mom , ownermom } ;

Haskell

The included snippet serves to give you an idea of the data structures, types, and names that we will be working with.

You can find the setup for each specific solution in:

haskell-lens: Contains the Lens apporach.

haskell-record-dot: Contains the Record Dot Syntax apporach.

Check out src/House.hs for the data structures, and src/Main.hs for all the examples throughout this post.

data Address = Address { country :: String , address :: String } deriving ( Show , Generic ) deriving ( ToJSON , FromJSON ) via CustomJSON '[ OmitNothingFields ] Address ) via'[ data Person = Person { id :: Int , firstname :: String , lastname :: String } deriving ( Show , Generic ) deriving ( ToJSON , FromJSON ) via CustomJSON '[ OmitNothingFields ] Person ) via'[ data Household = Household { id :: Int , people :: [ Person ] , address :: Maybe Address , alternativeAddress :: Maybe Address , owner :: Person } deriving ( Show , Generic ) deriving ( ToJSON , FromJSON ) via CustomJSON '[ OmitNothingFields ] Household ) via'[ = Household house { id = 1 = [mom, dad, son] , people[mom, dad, son] = Just addr , addressaddr = Nothing , alternativeAddress = mom , ownermom } where = Address { country = "Ocean" , address = "Under the sea" } addr{ country, address = Person { id = 1 , firstname = "Ariel" , lastname = "Swanson" } mom, firstname, lastname = Person { id = 2 , firstname = "Triton" , lastname = "Swanson" } dad, firstname, lastname = Person { id = 3 , firstname = "Eric" , lastname = "Swanson" } son, firstname, lastname

To allow overlapping record fields, we use DuplicateRecordFields along with OverloadedLabels (only in the Lens version), and a bunch of other extensions for deriving things via generics.

We control the details of the JSON serialization / deserialization using the derive-aeson package + the DerivingVia language extension.

Rust

The full setup can be found in rust-serde. Check out src/house.rs for the data structures, and src/main.rs for all the examples throughout this post.

#[ derive ( Serialize , Deserialize , Debug , Clone )] deriveSerializeDeserialize pub struct Address { Address pub country : String , country pub address : String , address } #[ derive ( Serialize , Deserialize , Debug , Clone )] deriveSerializeDeserialize pub struct Person { Person pub id : u32 , id pub firstname : String , firstname pub lastname : String , lastname } #[ derive ( Serialize , Deserialize , Debug , Clone )] deriveSerializeDeserialize #[ serde ( rename_all = "camelCase" )] serderename_all pub struct Household { Household pub id : u32 , id pub people : Vec < Person >, peoplePerson #[ serde ( skip_serializing_if = "Option::is_none" )] serdeskip_serializing_if pub address : Option < Address >, addressAddress #[ serde ( skip_serializing_if = "Option::is_none" )] serdeskip_serializing_if pub alternative_address : Option < Address >, alternative_addressAddress pub owner : Person , ownerPerson } pub fn house() -> Household { house()Household let addr = Address { country : "Ocean" . to_string() , address : "Under the sea" . to_string() }; addrAddresscountryto_string()addressto_string() let mom = Person { id : 1 , firstname : "Ariel" . to_string() , lastname : "Swanson" . to_string() }; momPersonidfirstnameto_string()lastnameto_string() let dad = Person { id : 2 , firstname : "Triton" . to_string() , lastname : "Swanson" . to_string() }; dadPersonidfirstnameto_string()lastnameto_string() let son = Person { id : 3 , firstname : "Eric" . to_string() , lastname : "Swanson" . to_string() }; sonPersonidfirstnameto_string()lastnameto_string() { Household : 1 , id : vec! [mom . clone() , dad , son] , people[momclone()dadson] : Some (addr) , address(addr) : None , alternative_address : mom ownermom } }

Comparison

If you wish to follow along, you can fire up a REPL for each approach. For the TypeScript and Rust versions, where we utilize mutability, we will clone the objects each time, to keep them consistent across examples and in our REPL.

💡 In TypeScript this would more commonly be done using the spread operator, ... , or using something like _.cloneDeep(value) .

TypeScript

$ cd typescript-json typescript-json $ npm i $ npm run repl run repl > import data from './house' data from > let newData newData

Haskell

$ cd haskell-lens haskell-lens $ stack build build $ stack ghci ghci *Main Data > Data

Unfortunately, GHC plugins don’t play nicely with ghci . We will instead build the project to play around with the examples in src/Main.hs .

$ cd haskell-record-dot haskell-record-dot $ stack build build $ # Open src/Main.hs in your editor $ stack run run

Rust

Since Rust doesn’t have a REPL, we will instead build the project, so we play around with the examples in src/main.rs .

$ cd rust-serde rust-serde $ cargo build build $ # Open src/main.rs in your editor $ cargo run run

Get a field

The first one is simple: we will get a value from our object.

First, our TypeScript version:

> data . house . owner data : 1 , firstname : 'Ariel' , lastname : 'Swanson' } { idfirstnamelastname

Let’s see how we achieve this in Haskell with Lenses:

* Main Data > house ^. # owner houseowner Person { id = 1 , firstname = "Ariel" , lastname = "Swanson" } , firstname, lastname

There’s probably already two unfamiliar pieces of syntax here.

The first, ^. , comes from Lens and is the view function that we use as an accessor to the object/record. The second, the # prefix of #owner , comes from the OverloadedLabels extension and allows us to have multiple record fields of the same name in scope.

Let’s see how we achieve this in Haskell with Record Dot Syntax:

. owner houseowner --> Person { id = 1 , firstname = "Ariel" , lastname = "Swanson" } , firstname, lastname

Finally, let’s check out Rust:

. owner houseowner --> Person { id : 1 , firstname : "Ariel" , lastname : "Swanson" } Personidfirstnamelastname

Get a nested field

We slowly increase the difficulty by accessing a nested field.

TypeScript:

> data . house . owner . firstname data 'Ariel'

Haskell with Lenses:

* Main Data > house ^. # owner . # firstname houseownerfirstname "Ariel"

Haskell with Record Dot Syntax:

. owner . firstname houseownerfirstname --> "Ariel"

Rust:

. owner . firstname houseownerfirstname --> "Ariel"

Get an optional field

How do we handle optional fields?

TypeScript:

// A field that exists. > data . house . address . address data 'Under the sea' // A field that does *NOT* exist (throws an exception.) > data . house . alternativeAddress . address data : Cannot read property 'address' of undefined TypeErrorCannot read propertyof .... at // A field that does *NOT* exist, using optional-chaining. > data . house . alternativeAddress ?. address data undefined

Optional chaining ( ? ) is a significant step toward writing safer and cleaner code in JS/TS.

Haskell with Lenses:

-- Return the value in a Maybe. * Main Data > house ^. # address houseaddress Just ( Address {country = "Ocean" , address = "Under the sea" }) {country, address}) -- A field on an object that exists. * Main Data > house ^. # address . # _Just . # address houseaddress_Justaddress "Under the sea" -- A field on an object that does *NOT* exist (falls back to an empty value.) * Main Data > house ^. # alternativeAddress . # _Just . # address housealternativeAddress_Justaddress ""

#_Just from Lens gives us convenient access to fields wrapped in Maybe s, with a fallback value.

Haskell with Record Dot Syntax:

-- Return the value in a Maybe. . address houseaddress --> Just ( Address {country = "Ocean" , address = "Under the sea" }) {country, address}) -- A field on an object that exists. maybe "" ( . address) house . address address) houseaddress --> "Under the sea" -- A field on an object that does *NOT* exist (falls back to an empty value.) maybe "" ( . address) house . alternativeAddress address) housealternativeAddress --> ""

We end up writing more regular code to dive into the Maybe value by using maybe to proceed or fallback to a default value.

Rust:

// Return the value in an Option. . address houseaddress --> Some (Address { country : "Ocean" , address : "Under the sea" } ) (Addresscountryaddress // A field on an object that exists. . address . and_then( | a | Some (a . address)) . unwrap_or( "" . to_string()) houseaddressand_then((aaddress))unwrap_or(to_string()) --> "Under the sea" // A field on an object that does *NOT* exist (falls back to an empty value.) . alternative_address . and_then( | a | Some (a . address)) . unwrap_or( "" . to_string()) housealternative_addressand_then((aaddress))unwrap_or(to_string()) --> ""

We utilize and_then a bit like maybe , passing a function to act on our value if it’s Some , and then creating a default case with unwrap_or .

Set a field

We’ll start with updating a non-nested field.

TypeScript:

> newData = JSON . parse ( JSON . stringify (data)) // Clone our data object. newData(data)) > const newAriel = { id : 4 , firstname : 'New Ariel' , lastname : 'Swandóttir' } const newAriel{ idfirstnamelastname > newData . house . owner = newAriel newDatanewAriel : 4 , firstname : 'New Ariel' , lastname : 'Swandóttir' } { idfirstnamelastname

Haskell with Lenses:

* Main Data > let newAriel = Person { id = 4 , firstname = "New Ariel" , lastname = "Swanson" } newAriel, firstname, lastname * Main Data > house & # owner .~ newAriel houseownernewAriel Household { {- Full Household object... -} }

We add two new pieces of syntax here. The & is a reverse application operator, but for all intents and purposes think of it as the ^. for setters. Finally, .~ is what allows us to actually set our value.

Haskell with Record Dot Syntax:

let newAriel = Person { id = 4 , firstname = "New Ariel" , lastname = "Swanson" } newAriel, firstname, lastname = newAriel} house{ ownernewAriel} --> Household { {- Full Household object... -} }

Pretty neat. Note that the lack of spacing in house{ is intentional.

Rust:

let mut new_house = house . clone() ; new_househouseclone() let new_ariel = Person { id : 4 , firstname : "New Ariel" . to_string() , lastname : "Swanson" . to_string() }; new_arielPersonidfirstnameto_string()lastnameto_string() . owner = new_ariel ; new_houseownernew_ariel --> Household { /* Full Household object... */ } Household

Alternatively we could use Rust’s Struct Update syntax, .. , which works much like the spread syntax ( ... ) in JavaScript. It would look something like Household { owner: new_ariel, ..house } .

Set a nested field

Now it gets a bit more tricky.

TypeScript:

> newData = JSON . parse ( JSON . stringify (data)) // Clone our data object. newData(data)) > newData . house . owner . firstname = 'New Ariel' newData 'New Ariel'

Haskell with Lenses:

* Main Data > house & # owner . # firstname .~ "New Ariel" houseownerfirstname Household { {- Full Household object... -} }

Note that we mix & and . to dig deeper into the object/record, much like accessing a nested field.

Haskell with Record Dot Syntax:

. firstname = "New Ariel" } house{ ownerfirstname --> Household { {- Full Household object... -} }

Note that the lack of spacing in house{ is actually important, at least in the current state of RecordDotSyntax.

Rust:

let mut new_house = house . clone() ; new_househouseclone() . owner . firstname = "New Ariel" . to_string() ; new_houseownerfirstnameto_string() --> Household { /* Full Household object... */ } Household

Set each item in a list

Let’s work a bit on the people list in our household. We’ll make those first names a bit more fresh.

TypeScript:

> newData = JSON . parse ( JSON . stringify (data)) // Clone our data object. newData(data)) > newData . house . people . forEach (person => { person . firstname = `Fly ${ person . firstname } ` }) newData(person{ personperson}) > newData . house . people newData [ : 1 , firstname : 'Fly Ariel' , lastname : 'Swanson' } , { idfirstnamelastname : 2 , firstname : 'Fly Triton' , lastname : 'Swanson' } , { idfirstnamelastname : 3 , firstname : 'Fly Eric' , lastname : 'Swanson' } { idfirstnamelastname ]

Haskell with Lenses:

-- You can usually also use `traverse` instead of `mapped` here. * Main Data > house & # people . mapped . # firstname %~ ( "Fly " <> ) housepeoplemappedfirstname Household { {- Full Household object... -} }

mapped allows us to map a function over all the values in #people .

Haskell with Record Dot Syntax:

= map (\p -> p{firstname = "Fly " ++ p . firstname}) house . people} house{ people(\pp{firstnamefirstname}) housepeople} --> Household { {- Full Household object... -} }

Using map feels very natural, and is quite close to the regular code you would write in Haskell.

Rust:

let mut new_house = house . clone() ; new_househouseclone() . people . iter_mut() . for_each( | p | p . firstname = format! ( "Fly {}" , p . firstname)) ; new_housepeopleiter_mut()for_each(firstnamefirstname)) --> Household { /* Full Household object... */ } Household

Encode / Serialize

Encoding JSON from our data is quite simple. In TypeScript/JavaScript it’s built-in, and in Haskell and Rust, we simply reach for Aeson and Serde. Each of the libraries gives us control over the details in various ways, such as omitting Nothing / None values.

TypeScript:

> JSON . stringify (data) (data) '{"mom": ... }'

Haskell with Lenses + Haskell with Record Dot Syntax:

-- You can usually also use `traverse` instead of `mapped` here. * Main Data > import Data.Aeson (encode) (encode) * Main Data Data.Aeson > encode house encode house "{\"id\":1, ... }}"

Rust:

let serialized = serde_json:: to_string( & house) . unwrap() ; serializedto_string(house)unwrap()

Decode / Deserialize

Decoding JSON into our data type is luckily also straightforward, although we will need to tell Haskell and Rust a bit more information than when encoding (as one would expect).

TypeScript:

> let houseJson = JSON . stringify (data) let houseJson(data) > JSON . parse (houseJson) (houseJson) { : { id : 1 , firstname : 'Ariel' , lastname : 'Swanson' } , mom{ idfirstnamelastname : { id : 2 , firstname : 'Triton' , lastname : 'Swanson' } , dad{ idfirstnamelastname : { id : 3 , firstname : 'Eric' , lastname : 'Swanson' } , son{ idfirstnamelastname : { house : 1 , id : [ [Object] , [Object] , [Object] ] , people[ [Object][Object][Object] ] : { country : 'Ocean' , address : 'Under the sea' } , address{ countryaddress : { id : 1 , firstname : 'Ariel' , lastname : 'Swanson' } owner{ idfirstnamelastname } }

Haskell with Lenses + Haskell with Record Dot Syntax:

-- Setting up imports and language extensions. * Main Data >: set - XTypeApplications set * Main Data > import Data.Aeson (decode, encode) (decode, encode) * Main Data Data.Aeson > let houseJson = encode house houseJsonencode house -- Our decoding. * Main Data Data.Aeson > decode @ Household houseJson decodehouseJson Just ( Household { id = 1 = , people [ Person { id = 1 , firstname = "Ariel" , lastname = "Swanson" } , firstname, lastname , Person { id = 2 , firstname = "Triton" , lastname = "Swanson" } , firstname, lastname , Person { id = 3 , firstname = "Eric" , lastname = "Swanson" } , firstname, lastname ] = Just ( Address {country = "Ocean" , address = "Under the sea" }) , address{country, address}) = Nothing , owner = Person { id = 1 , firstname = "Ariel" , lastname = "Swanson" } , alternativeAddress, owner, firstname, lastname } )

Since we are in the REPL, we manually enable the TypeApplications language extension. We then use this when decoding, in @Household , to let Haskell know what data type we are trying to convert this random string into.

Alternatively, we could have written (decode houseJson) :: Maybe Household . The Maybe is what the decoder wraps the value in, in case we fed it a malformed JSON string.

Rust:

let house_json = serde_json:: to_string( & house) . unwrap() ; house_jsonto_string(house)unwrap() let deserialize : Household = serde_json:: from_str( & house_json) . unwrap() ; deserializeHouseholdfrom_str(house_json)unwrap() --> Household { Household : 1 , id : [ people { id : 1 , firstname : "Ariel" , lastname : "Swanson" , }, Personidfirstnamelastname { id : 2 , firstname : "Triton" , lastname : "Swanson" , }, Personidfirstnamelastname { id : 3 , firstname : "Eric" , lastname : "Swanson" , }, Personidfirstnamelastname ] , : Some (Address { country : "Ocean" , address : "Under the sea" , } ) , address(Addresscountryaddress : None , alternative_address : Person { id : 1 , firstname : "Ariel" , lastname : "Swanson" , }, ownerPersonidfirstnamelastname }

Like with Haskell, we let Rust know what data type we are trying to convert our random string into. We do this by annotating the type of deserialize to with deserialize: Household . The unwrap here is for convenience, but in real code, you’re probably more likely to do serde_json::from_str(&house_json)? instead.





Have other common patterns you’d like to see? Feel like some of the approaches could be improved? Leave a comment, and I will try to expand this list to be more comprehensive!

Changelog

Thanks to all the feedback from the /r/rust and /r/haskell communities, the following changes have been made: