Data Serialization in Rust with Serde

In our digital age, data is the lifeblood of countless applications. But there’s a tiny secret behind the scenes: the art and science of…

Data Serialization in Rust with Serde

In our digital age, data is the lifeblood of countless applications. But there’s a tiny secret behind the scenes: the art and science of data conversion. Think about it — how does data smoothly transition from complex structures to a neat, shareable format and back? Enter the world of serialisation.

If you’re working with Rust, one hero in this realm stands head and shoulders above the rest: Serde. Not only is it powerful, but it’s also remarkably friendly to use. So, whether you’re looking to simplify data storage, facilitate data transfer, or are just curious about Rust’s capabilities, join us as we explore data serialisation with Serde.

Ready to dive in? Let’s go! 🚀

What is Serialization?

Serialisation converts complex data structures, such as structs, enums, and other composite types, into a flat, byte-based representation. The reverse process, called deserialisation, involves converting that byte-based representation to its original structure.

Why Serde?

There are several reasons why Serde stands out in Rust’s ecosystem:

  1. Performance: Serde is known for its high-speed serialisation and deserialisation.
  2. Flexibility: It supports numerous data formats, including JSON, TOML, YAML, and Binary.
  3. Customizability: Serde allows developers to define their types’ custom serialisation and deserialisation logic.

Getting Started with Serde

To start using Serde, you need to include the Serde library and its dependencies in your Cargo.toml:

[dependencies] 
serde = { version = "1", features = ["derive"] } 
serde_json = "1"

In this example, we’ll focus on JSON serialisation.

Serialising and Deserialising with Serde

Here’s a basic example of serialising and deserialising a Rust struct with Serde:

extern crate serde; 
extern crate serde_json; 
 
use serde::{Serialize, Deserialize}; 
#[derive(Serialize, Deserialize, Debug)] 
struct Person { 
    name: String, 
    age: u8, 
} 
fn main() { 
    let person = Person { name: "Alice".to_string(), age: 30 }; 
    // Serialize to JSON string 
    let serialized = serde_json::to_string(&person).unwrap(); 
    println!("Serialized: {}", serialized); 
    // Deserialize from JSON string 
    let deserialized: Person = serde_json::from_str(&serialized).unwrap(); 
    println!("Deserialized: {:?}", deserialized); 
}

Implementing Custom Data Types

Imagine you want to store a date in the format dd-MM-yyyy. Rust's standard library doesn't use this format by default, so you need a custom implementation:

use serde::{Serialize, Deserialize, Serializer, Deserializer}; 
use chrono::{NaiveDate, Datelike, FormatError}; 
 
#[derive(Debug)] 
struct CustomDate(NaiveDate); 
impl Serialize for CustomDate { 
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 
    where 
        S: Serializer, 
    { 
        let formatted = format!("{:02}-{:02}-{}", self.0.day(), self.0.month(), self.0.year()); 
        serializer.serialize_str(&formatted) 
    } 
} 
impl<'de> Deserialize<'de> for CustomDate { 
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 
    where 
        D: Deserializer<'de>, 
    { 
        let s = String::deserialize(deserializer)?; 
        NaiveDate::parse_from_str(&s, "%d-%m-%Y").map_err(serde::de::Error::custom).map(CustomDate) 
    } 
}

Working with External Structs

Sometimes, you might want to serialise or deserialise types from external crates that don’t implement Serialize and Deserialize. With Serde, you can use newtype pattern:

use std::net::IpAddr; 
 
#[derive(Serialize, Deserialize)] 
struct SerializableIpAddr(#[serde(with = "ip_addr_as_string")] IpAddr); 
mod ip_addr_as_string { 
    use std::net::IpAddr; 
    use serde::{Serialize, Deserialize, Serializer, Deserializer}; 
    pub fn serialize<S>(addr: &IpAddr, serializer: S) -> Result<S::Ok, S::Error> 
    where 
        S: Serializer, 
    { 
        serializer.serialize_str(&addr.to_string()) 
    } 
    pub fn deserialize<'de, D>(deserializer: D) -> Result<IpAddr, D::Error> 
    where 
        D: Deserializer<'de>, 
    { 
        let s = String::deserialize(deserializer)?; 
        s.parse().map_err(serde::de::Error::custom) 
    } 
}

Conditionally Skip Fields

You might want to skip serialising certain fields based on their values in certain scenarios. Serde allows conditional checks for this:

#[derive(Serialize, Deserialize, Debug)] 
struct Product { 
    name: String, 
    #[serde(skip_serializing_if = "Option::is_none")] 
    description: Option<String>, 
}

In this example, if description is None, it will be skipped during serialisation.

Use of Enums to Represent Variants

Enums in Rust can be used effectively to represent various data variants, especially when working with JSON objects that could have different structures:

#[derive(Serialize, Deserialize, Debug)] 
#[serde(tag = "type")] 
enum Event { 
    Login { username: String, timestamp: u64 }, 
    Logout { user_id: u32, timestamp: u64 }, 
}

This will serialise to either:

{ 
  "type": "Login", 
  "username": "Alice", 
  "timestamp": 1633287843 
}

Or:

{ 
  "type": "Logout", 
  "user_id": 42, 
  "timestamp": 1633287843 
}

Handling Nested JSON with Serde

One common scenario is dealing with nested JSON data. Let’s explore how Serde can be leveraged for such structures.

Imagine you have the following JSON:

{ 
  "user": { 
    "name": "Alice", 
    "contact": { 
      "email": "alice@example.com", 
      "phone": "1234567890" 
    }, 
    "address": { 
      "city": "Wonderland", 
      "zipcode": "12345" 
    } 
  } 
}

With Serde, you can easily create nested Rust structs to represent this data:

#[derive(Serialize, Deserialize, Debug)] 
struct User { 
    name: String, 
    contact: Contact, 
    address: Address, 
} 
 
#[derive(Serialize, Deserialize, Debug)] 
struct Contact { 
    email: String, 
    phone: String, 
} 
#[derive(Serialize, Deserialize, Debug)] 
struct Address { 
    city: String, 
    zipcode: String, 
}

Serialising Collections

Collections like Vec, HashMap, and others often represent lists or dictionaries in Rust. With Serde, you can effortlessly serialise and deserialise these data structures:

use std::collections::HashMap; 
 
#[derive(Serialize, Deserialize, Debug)] 
struct Inventory { 
    items: Vec<String>, 
    stock: HashMap<String, u32>, 
} 
fn main() { 
    let inv = Inventory { 
        items: vec!["apple".to_string(), "banana".to_string()], 
        stock: [("apple".to_string(), 50), ("banana".to_string(), 30)] 
            .iter().cloned().collect(), 
    }; 
    let serialized = serde_json::to_string(&inv).unwrap(); 
    println!("Serialized: {}", serialized); 
    let deserialized: Inventory = serde_json::from_str(&serialized).unwrap(); 
    println!("Deserialized: {:?}", deserialized); 
}

Error Handling with Serde

Error handling is crucial when working with external data. Serde’s error type provides comprehensive information about what went wrong during serialisation or deserialisation:

fn main() { 
    let data = r#" { "name": "Alice", "age": "thirty" } "#; 
 
    let result: Result<Person, _> = serde_json::from_str(data); 
    match result { 
        Ok(_) => println!("Successfully deserialized data."), 
        Err(err) => println!("Deserialization error: {}", err), 
    } 
}

In the above example, deserialising the string “thirty” into a u8 will produce an error, which is then printed out.

Using Serde with Other Formats

While JSON is one of the most popular data formats, Serde supports many others. For instance, to work with TOML:

  1. Add serde and toml to your Cargo.toml:
[dependencies] 
serde = { version = "1", features = ["derive"] } 
toml = "0.5"

2. Serialize and deserialize:

use serde::{Serialize, Deserialize}; 
 
#[derive(Serialize, Deserialize, Debug)] 
struct Config { 
    database: Database, 
} 
#[derive(Serialize, Deserialize, Debug)] 
struct Database { 
    user: String, 
    password: String, 
} 
fn main() { 
    let toml_str = r#" 
    [database] 
    user = "admin" 
    password = "password123" 
    "#; 
    let config: Config = toml::from_str(toml_str).unwrap(); 
    println!("{:?}", config); 
}

Similarly, Serde can be paired with other crates like serde_yaml for YAML and bincode for binary serialisation, broadening its utility.

Renaming and Ignoring Fields

Sometimes the serialised representation of your data doesn’t align with your Rust struct’s field names. In such cases, Serde provides attributes to rename or ignore fields:

#[derive(Serialize, Deserialize, Debug)] 
struct Person { 
    #[serde(rename = "fullName")] 
    name: String, 
    age: u8, 
    #[serde(skip_serializing_if = "Option::is_none")] 
    phone_number: Option<String>, 
}

In the above example, the name field will be serialised as fullName and the phone_number field will only be serialised if it contains a value.

Default Values

When deserialising data, it’s common to encounter missing fields. Serde provides a way to specify default values:

#[derive(Serialize, Deserialize, Debug)] 
struct Person { 
    name: String, 
    #[serde(default = "default_age")] 
    age: u8, 
} 
 
fn default_age() -> u8 { 
    18 
}

If the age is absent during deserialisation, it will default to 18.

Flattening

There are instances when nested structures can be flattened during serialisation. Serde’s flatten attribute comes in handy:

#[derive(Serialize, Deserialize, Debug)] 
struct Address { 
    city: String, 
    country: String, 
} 
 
#[derive(Serialize, Deserialize, Debug)] 
struct Person { 
    name: String, 
    age: u8, 
    #[serde(flatten)] 
    address: Address, 
}

With flattening, instead of having a nested JSON object for the address, both city and country will be at the same level as name and age.


Read more articles about Rust in my Rust Programming Library!


Happy coding and keep those gears turning! 🦀🔧🚀

Read more articles about Rust in my Rust Programming Library!

Visit my Blog for more articles, news, and software engineering stuff!

Follow me on Medium, LinkedIn, and Twitter.

All the best,

CTO | Tech Lead | Senior Software Engineer | Cloud Solutions Architect | Rust 🦀 | Golang | Java | ML AI & Statistics | Web3 & Blockchain

Read more