Support Ukraine. DONATE.

When serde_json::to_string() fails

I've been using serde and serde_json for a few years almost in every Rust project I had.

It's pretty clear why serde_json::from_str() returns Result, many things that can go wrong on deserialization (malformed JSON, missing fields, wrong field types, etc.).

But what about serde_json::to_string()? This one returns Result too. In my practice, I've never seen (until very recently) serialization errors, and logically thinking: if a data structure cannot be serialized, why should it be compiled at all?

The code

Let's say we're working on an app to store the preferences of our friends. For the sake of simplicity, we'll focus only on a limited set of drinks.

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
#[serde(tag = "t")]
enum Drink {
    Mate,
    Water { sparkling: bool }
}

#[derive(Serialize, Deserialize)]
struct Preferences {
    drink: Drink
}

fn main() {
    let preferences = Preferences {
        drink: Drink::Water { sparkling: false }
    };
    let json = serde_json::to_string(&preferences).unwrap();
    println!("{json}");
}

This prints the following JSON:

{
    "drink": {
        "t": "Water",
        "sparkling": false
    }
}

Notice, the .unwrap(). We know for sure it will never panic: we can have only 3 different drinks: mate, sparkling water and still water. None of these variants will ever break the serialization. That's guaranteed.

Over time the code evolves

As time goes by, all of our friends switched their nutrition from sparkling water to still water. You've decided that sparkling: bool is obsolete.

Also, we realized that we need to track where a particular drink is stored in the kitchen. For that, we introduce a new enum Location and we keep track of the drinks as HashMap<Drink, Location>. Of course, to be able to use Drink as the key for the hashmap it should derive Hash, PartialEq and Eq:

The following snippet illustrates the changes and tries to serialize HashMap<Drink, Location>.

use serde::{Serialize, Deserialize};
use std::collections::HashMap;

#[derive(Serialize, Deserialize, Hash, PartialEq, Eq)]
#[serde(tag = "t")]
enum Drink {
    Mate,
    Water,
}

#[derive(Serialize, Deserialize)]
enum Location {
    Fridge,
    Cupboard
}

fn main() {
    let mut storage = HashMap::new();
    storage.insert(Drink::Mate, Location::Cupboard);
    storage.insert(Drink::Water, Location::Fridge);

    let json = serde_json::to_string(&storage).unwrap();
    println!("{json}");
}

Oh, but this time serialization fails!

thread 'main' panicked at 'called `Result::unwrap()`
on an `Err` value: Error("key must be a string", line: 0, column: 0)'

¡Qué pena!

What does it mean "key must be a string"? How such a simple enum is not a string?

enum Drink { Mate, Water }

Let's proof, that it's serialized to string:

let json = serde_json::to_string(&Drink::Mate).unwrap();
println!("{json}");
{"t":"Mate"}

Oh. It's serialized to object.

While refactoring, we completely forgot that #[serde(tag = "t")] makes a simple enum serialize as a JSON object, and therefore it cannot be used as a JSON key.

Albeit it seems to be obvious and reasonable now, unfortunately, the compiler could not prevent the error, and it was encountered only at runtime.

Summary

Luckily such an error was very easy to detect. Clearly, it was an error caused by our code and not by data.

We all love to think that "if it compiles it works", but it's only true until it's not. There are certain programming techniques that allows us to move some errors from runtime to compile time, but we should not give up on automated and manual testing.