๐Ÿฆ€ Functional Rust

759: Manual Serialize/Deserialize Trait Implementation

Difficulty: 3 Level: Intermediate Implement `Serialize` and `Deserialize` traits by hand โ€” understand what `serde` generates, build a custom wire format, and see the visitor pattern in action.

The Problem This Solves

Most Rust projects use `serde` with `#[derive(Serialize, Deserialize)]` โ€” which is excellent for JSON, TOML, bincode, and dozens of other formats. But there are situations where you need a custom wire format: a binary protocol, a legacy text format, a space-constrained embedded system, or a format that serde doesn't support. Even when you use serde, understanding what it does makes you a better user of it. The `Serialize` trait, the `Serializer` interface, and the visitor pattern are the same whether the framework generates them or you write them. When serde's derive macro doesn't do what you need, you implement the trait manually โ€” and this example shows exactly how. There's also a practical need for lightweight serialization that avoids `serde`'s compilation overhead. If you're writing a library with minimal dependencies, rolling a simple key=value format can be the right call.

The Intuition

Think of Python's `__repr__` and custom `json.JSONEncoder` โ€” you define how your type serializes to a string, and a matching parser that reconstructs it. In Rust, you formalize this as a pair of traits with a wire format contract between them. The key insight is the round-trip property: `deserialize(serialize(x)) == x` must hold for all valid values. This is what you test. Once the round-trip holds โ€” including for edge cases like strings containing the delimiter โ€” you can trust the format. Escape handling is where custom formats usually fail. If your delimiter is `|` and your string can contain `|`, you need escaping. This example shows a complete escape/unescape cycle.

How It Works in Rust

// The trait pair โ€” serialize to String, deserialize from &str
pub trait Serialize {
 fn serialize(&self) -> String;
}

pub trait Deserialize: Sized {
 fn deserialize(input: &str) -> Result<Self, SerError>;
}

// Implement for your domain type
#[derive(Debug, PartialEq)]
pub struct Person { pub name: String, pub age: u32, pub active: bool }

impl Serialize for Person {
 fn serialize(&self) -> String {
     // name=Alice|Wonder โ†’ escape | in name as \|
     format!("name={}|age={}|active={}",
         escape(&self.name),
         self.age,
         self.active)
 }
}

impl Deserialize for Person {
 fn deserialize(input: &str) -> Result<Self, SerError> {
     let fields = parse_fields(input);  // splits on unescaped |, then on =

     let name = fields.get("name")
         .ok_or_else(|| SerError::MissingField("name".into()))?
         .clone();

     let age = fields.get("age")
         .ok_or_else(|| SerError::MissingField("age".into()))?
         .parse::<u32>()
         .map_err(|e| SerError::ParseError(e.to_string()))?;

     // ... active field similarly
     Ok(Person { name, age, active })
 }
}

// Generic round-trip โ€” works for any Serialize + Deserialize type
fn round_trip<T: Serialize + Deserialize + Debug>(value: &T) -> Result<T, SerError> {
 T::deserialize(&value.serialize())
}

// Test: special characters in string fields survive the round-trip
#[test]
fn round_trip_special_chars() {
 let p = Person { name: "Pi|pe".to_string(), age: 1, active: false };
 let decoded = round_trip(&p).unwrap();
 assert_eq!(p, decoded);  // "|" in name must survive escape/unescape
}
Key points:

What This Unlocks

Key Differences

ConceptOCamlRust
Serialization`yojson`, `marshal`, or custom ppx`serde` with `#[derive]` or manual trait impl
Trait pairModule signature with `encode`/`decode``Serialize` + `Deserialize` traits
Missing field errorException or `Not_found``SerError::MissingField(field_name)`
Parse error`Failure` exception`SerError::ParseError(msg)`
Round-trip testCustom equality check`assert_eq!(original, round_trip(&original).unwrap())`
Escape handlingManual string manipulationSame โ€” no magic, must be explicit
// 759. Manual Serialize/Deserialize Trait Implementation
// std-only โ€” no external crates

use std::collections::HashMap;
use std::fmt;

// โ”€โ”€ Error type โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

#[derive(Debug)]
pub enum SerError {
    InvalidFormat(String),
    MissingField(String),
    ParseError(String),
}

impl fmt::Display for SerError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::InvalidFormat(s) => write!(f, "InvalidFormat: {s}"),
            Self::MissingField(s) => write!(f, "MissingField: {s}"),
            Self::ParseError(s) => write!(f, "ParseError: {s}"),
        }
    }
}

// โ”€โ”€ Traits โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

/// Something that can turn itself into a key=value string.
pub trait Serialize {
    fn serialize(&self) -> String;
}

/// Something that can reconstruct itself from a key=value string.
pub trait Deserialize: Sized {
    fn deserialize(input: &str) -> Result<Self, SerError>;
}

// โ”€โ”€ Wire-format helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

fn escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 4);
    for c in s.chars() {
        if c == '|' || c == '\\' {
            out.push('\\');
        }
        out.push(c);
    }
    out
}

fn unescape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut chars = s.chars();
    while let Some(c) = chars.next() {
        if c == '\\' {
            if let Some(next) = chars.next() {
                out.push(next);
            }
        } else {
            out.push(c);
        }
    }
    out
}

fn parse_fields(input: &str) -> HashMap<&str, String> {
    let mut map = HashMap::new();
    for field in input.split('|') {
        if let Some(eq) = field.find('=') {
            let key = &field[..eq];
            let val = unescape(&field[eq + 1..]);
            map.insert(key, val);
        }
    }
    map
}

// โ”€โ”€ Domain type โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

#[derive(Debug, PartialEq)]
pub struct Person {
    pub name: String,
    pub age: u32,
    pub active: bool,
}

impl Serialize for Person {
    fn serialize(&self) -> String {
        format!(
            "name={}|age={}|active={}",
            escape(&self.name),
            self.age,
            self.active
        )
    }
}

impl Deserialize for Person {
    fn deserialize(input: &str) -> Result<Self, SerError> {
        let fields = parse_fields(input);

        let name = fields
            .get("name")
            .ok_or_else(|| SerError::MissingField("name".into()))?
            .clone();

        let age = fields
            .get("age")
            .ok_or_else(|| SerError::MissingField("age".into()))?
            .parse::<u32>()
            .map_err(|e| SerError::ParseError(e.to_string()))?;

        let active = fields
            .get("active")
            .ok_or_else(|| SerError::MissingField("active".into()))?
            .parse::<bool>()
            .map_err(|e| SerError::ParseError(e.to_string()))?;

        Ok(Person { name, age, active })
    }
}

// โ”€โ”€ Generic round-trip helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

fn round_trip<T>(value: &T) -> Result<T, SerError>
where
    T: Serialize + Deserialize + std::fmt::Debug,
{
    let encoded = value.serialize();
    T::deserialize(&encoded)
}

// โ”€โ”€ main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

fn main() {
    let alice = Person {
        name: "Alice|Wonder".to_string(),
        age: 30,
        active: true,
    };

    let encoded = alice.serialize();
    println!("Encoded : {encoded}");

    match Person::deserialize(&encoded) {
        Ok(p) => println!("Decoded : {p:?}"),
        Err(e) => println!("Error   : {e}"),
    }

    // Generic helper
    let bob = Person { name: "Bob".to_string(), age: 25, active: false };
    let bob2 = round_trip(&bob).expect("round-trip failed");
    println!("Round-trip Bob: {bob2:?}");
}

// โ”€โ”€ Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn round_trip_normal() {
        let p = Person { name: "Carol".to_string(), age: 42, active: true };
        let encoded = p.serialize();
        let decoded = Person::deserialize(&encoded).unwrap();
        assert_eq!(p, decoded);
    }

    #[test]
    fn round_trip_special_chars() {
        let p = Person { name: "Pi|pe".to_string(), age: 1, active: false };
        let encoded = p.serialize();
        let decoded = Person::deserialize(&encoded).unwrap();
        assert_eq!(p, decoded);
    }

    #[test]
    fn missing_field_error() {
        let result = Person::deserialize("name=Eve|active=true");
        assert!(matches!(result, Err(SerError::MissingField(_))));
    }

    #[test]
    fn bad_age_parse() {
        let result = Person::deserialize("name=Eve|age=notanumber|active=true");
        assert!(matches!(result, Err(SerError::ParseError(_))));
    }
}
(* Manual Serialize / Deserialize in OCaml
   We define a module type that mirrors Rust's Serialize trait,
   then implement it for a concrete record type. *)

(* ---------- Serializer "trait" as a module type ---------- *)
module type SERIALIZER = sig
  type t
  val serialize : t -> string
  val deserialize : string -> t option
end

(* ---------- A simple key=value wire format ---------- *)
(* Helper: escape '|' and '\' in strings *)
let escape s =
  let buf = Buffer.create (String.length s + 4) in
  String.iter (fun c ->
    if c = '|' || c = '\\' then Buffer.add_char buf '\\';
    Buffer.add_char buf c
  ) s;
  Buffer.contents buf

let unescape s =
  let buf = Buffer.create (String.length s) in
  let len = String.length s in
  let i = ref 0 in
  while !i < len do
    if s.[!i] = '\\' && !i + 1 < len then begin
      Buffer.add_char buf s.[!i + 1];
      i := !i + 2
    end else begin
      Buffer.add_char buf s.[!i];
      i := !i + 1
    end
  done;
  Buffer.contents buf

(* ---------- Domain type ---------- *)
type person = { name: string; age: int; active: bool }

(* ---------- Implementation ---------- *)
module PersonSerializer : SERIALIZER with type t = person = struct
  type t = person

  let serialize p =
    Printf.sprintf "name=%s|age=%d|active=%b"
      (escape p.name) p.age p.active

  let deserialize s =
    let fields = String.split_on_char '|' s in
    let tbl = Hashtbl.create 4 in
    List.iter (fun field ->
      match String.split_on_char '=' field with
      | k :: rest -> Hashtbl.replace tbl k (unescape (String.concat "=" rest))
      | [] -> ()
    ) fields;
    try
      let name   = Hashtbl.find tbl "name" in
      let age    = int_of_string (Hashtbl.find tbl "age") in
      let active = bool_of_string (Hashtbl.find tbl "active") in
      Some { name; age; active }
    with Not_found | Failure _ -> None
end

(* ---------- Demo ---------- *)
let () =
  let alice = { name = "Alice|Wonder"; age = 30; active = true } in
  let encoded = PersonSerializer.serialize alice in
  Printf.printf "Encoded : %s\n" encoded;
  match PersonSerializer.deserialize encoded with
  | Some p ->
    Printf.printf "Decoded : name=%s age=%d active=%b\n" p.name p.age p.active
  | None ->
    Printf.printf "Decode failed!\n"