Why a typed builder is worth it

JSON Patch is defined by RFC 6902. Each operation has a specific shape:

  • add: insert a value at a path
  • remove: delete a value at a path
  • replace: update a value at a path
  • move: relocate a value from one path to another
  • copy: duplicate a value from one path to another
  • test: assert that a path contains an expected value

A raw serde_json::Value approach works, but it is easy to make mistakes:

  • forgetting the op field
  • using the wrong field for an operation
  • building invalid JSON Pointer paths
  • mixing up from and path
  • serializing values with the wrong shape

A typed builder reduces those errors by encoding operation rules in Rust types. The result is a small API that is easier to use correctly than to misuse.

The design goals

For this tutorial, we will build a JSON Patch builder with these properties:

  • operations are represented by Rust structs and enums
  • paths are validated as JSON Pointers
  • values are serialized with serde
  • the final patch serializes to standard JSON Patch format
  • the API is ergonomic enough for application code

This is not a full patch application engine. It is a construction API for generating patches safely.

Project setup

Add these dependencies to Cargo.toml:

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

We will use serde_json::Value for patch values and thiserror for path validation errors.

Modeling JSON Pointer paths

JSON Patch uses JSON Pointer syntax, such as:

  • /name
  • /address/street
  • /items/0

A typed path wrapper helps avoid accidental invalid strings.

use std::fmt;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JsonPointer(String);

#[derive(Debug, thiserror::Error)]
pub enum PointerError {
    #[error("JSON Pointer must start with '/' or be empty")]
    InvalidStart,
    #[error("JSON Pointer contains invalid escape sequence")]
    InvalidEscape,
}

impl JsonPointer {
    pub fn parse(input: impl Into<String>) -> Result<Self, PointerError> {
        let s = input.into();

        if !s.is_empty() && !s.starts_with('/') {
            return Err(PointerError::InvalidStart);
        }

        let bytes = s.as_bytes();
        let mut i = 0;
        while i < bytes.len() {
            if bytes[i] == b'~' {
                if i + 1 >= bytes.len() || (bytes[i + 1] != b'0' && bytes[i + 1] != b'1') {
                    return Err(PointerError::InvalidEscape);
                }
                i += 2;
            } else {
                i += 1;
            }
        }

        Ok(Self(s))
    }

    pub fn root() -> Self {
        Self(String::new())
    }
}

impl fmt::Display for JsonPointer {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.0.fmt(f)
    }
}

This wrapper is intentionally simple. It validates the basic pointer structure and keeps the internal string private.

Why not use plain String?

A String does not tell you whether the value is a valid JSON Pointer. By introducing JsonPointer, you make invalid state harder to represent. That is a common Rust pattern: move validation to construction time, then rely on the type afterward.

Defining patch operations

Next, we model each JSON Patch operation as a Rust enum. Some operations need one path and a value; others need two paths.

use serde::Serialize;
use serde_json::Value;

#[derive(Debug, Clone, Serialize)]
#[serde(tag = "op", rename_all = "lowercase")]
pub enum PatchOperation {
    Add {
        path: JsonPointer,
        value: Value,
    },
    Remove {
        path: JsonPointer,
    },
    Replace {
        path: JsonPointer,
        value: Value,
    },
    Move {
        from: JsonPointer,
        path: JsonPointer,
    },
    Copy {
        from: JsonPointer,
        path: JsonPointer,
    },
    Test {
        path: JsonPointer,
        value: Value,
    },
}

This enum serializes directly into JSON Patch objects. The serde(tag = "op") attribute ensures each variant includes the correct operation name.

Example serialized output

A replace operation like this:

PatchOperation::Replace {
    path: JsonPointer::parse("/name").unwrap(),
    value: serde_json::json!("Ada"),
}

serializes to:

{
  "op": "replace",
  "path": "/name",
  "value": "Ada"
}

Building a fluent patch builder

A builder makes patch creation more ergonomic, especially when generating multiple operations in sequence.

#[derive(Debug, Default, Clone)]
pub struct JsonPatchBuilder {
    ops: Vec<PatchOperation>,
}

impl JsonPatchBuilder {
    pub fn new() -> Self {
        Self { ops: Vec::new() }
    }

    pub fn add(mut self, path: JsonPointer, value: impl Into<Value>) -> Self {
        self.ops.push(PatchOperation::Add {
            path,
            value: value.into(),
        });
        self
    }

    pub fn remove(mut self, path: JsonPointer) -> Self {
        self.ops.push(PatchOperation::Remove { path });
        self
    }

    pub fn replace(mut self, path: JsonPointer, value: impl Into<Value>) -> Self {
        self.ops.push(PatchOperation::Replace {
            path,
            value: value.into(),
        });
        self
    }

    pub fn move_path(mut self, from: JsonPointer, path: JsonPointer) -> Self {
        self.ops.push(PatchOperation::Move { from, path });
        self
    }

    pub fn copy(mut self, from: JsonPointer, path: JsonPointer) -> Self {
        self.ops.push(PatchOperation::Copy { from, path });
        self
    }

    pub fn test(mut self, path: JsonPointer, value: impl Into<Value>) -> Self {
        self.ops.push(PatchOperation::Test {
            path,
            value: value.into(),
        });
        self
    }

    pub fn build(self) -> Vec<PatchOperation> {
        self.ops
    }
}

This builder uses a consuming API, which is convenient for chaining:

let patch = JsonPatchBuilder::new()
    .replace(JsonPointer::parse("/name").unwrap(), "Grace")
    .add(JsonPointer::parse("/active").unwrap(), true)
    .remove(JsonPointer::parse("/deprecated").unwrap())
    .build();

A practical example: updating a user profile

Imagine an API that stores user profiles as JSON documents. A client wants to update only a few fields after editing a form.

use serde_json::json;

fn build_profile_patch() -> Result<Vec<PatchOperation>, PointerError> {
    let patch = JsonPatchBuilder::new()
        .replace(JsonPointer::parse("/profile/display_name")?, json!("Mira Chen"))
        .replace(JsonPointer::parse("/profile/timezone")?, json!("UTC"))
        .test(JsonPointer::parse("/version")?, json!(12))
        .build();

    Ok(patch)
}

This patch says:

  1. update the display name
  2. update the timezone
  3. verify the document version before applying changes

That last test operation is especially useful in concurrent systems. It allows optimistic concurrency checks without requiring a separate round trip.

Serializing the patch to JSON

Because the operations derive Serialize, turning the patch into JSON is straightforward.

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let patch = JsonPatchBuilder::new()
        .replace(JsonPointer::parse("/name")?, "Ada")
        .add(JsonPointer::parse("/tags/-")?, "rust")
        .build();

    let json = serde_json::to_string_pretty(&patch)?;
    println!("{json}");

    Ok(())
}

The output will be a JSON array of patch objects. Note the /- path segment, which appends to the end of an array in JSON Patch.

Handling special pointer cases

JSON Pointer has a few escaping rules:

  • ~ becomes ~0
  • / becomes ~1

That matters when your keys contain special characters. For example, a field named a/b must be addressed as /a~1b.

A production-grade builder should probably include helper functions to escape path segments rather than requiring callers to write escaped pointers manually.

Segment-based path construction

You can improve the API by adding a path constructor from segments:

impl JsonPointer {
    pub fn from_segments(segments: &[&str]) -> Self {
        let mut out = String::new();

        for segment in segments {
            out.push('/');
            for ch in segment.chars() {
                match ch {
                    '~' => out.push_str("~0"),
                    '/' => out.push_str("~1"),
                    _ => out.push(ch),
                }
            }
        }

        Self(out)
    }
}

This is safer than formatting raw strings because it handles escaping automatically.

Best practices for a typed patch API

PracticeWhy it helps
Validate pointers at construction timePrevents malformed patch paths from reaching runtime logic
Use serde for serializationKeeps the wire format standard and predictable
Model operations as an enumMakes invalid operation combinations impossible
Prefer segment-based path buildersAvoids manual escaping mistakes
Use test for optimistic concurrencyReduces lost updates in multi-client systems

A few additional recommendations:

  • Keep the builder small and focused on construction.
  • Avoid adding application-specific business rules into the patch type itself.
  • If your API accepts user input, validate paths before building operations.
  • Consider exposing both a fluent builder and direct enum constructors for flexibility.

When this approach is useful

A typed JSON Patch builder is a good fit for:

  • REST APIs that support partial updates
  • frontends syncing local edits to a server
  • document databases with patch semantics
  • configuration editors that need precise field updates
  • collaborative applications with version checks

It is less useful when you always replace entire documents, or when your update logic is highly domain-specific and not naturally expressed as patch operations.

Extending the design

There are several natural extensions:

  • add a PatchDocument wrapper around Vec<PatchOperation>
  • support strongly typed domain paths like UserPath::ProfileDisplayName
  • integrate with serde_json::Map for object-specific helpers
  • implement deserialization for incoming patch documents
  • add a patch application engine for in-memory documents

A particularly useful extension is domain-specific path enums. For example, a UserField enum can map safe application fields to JSON Pointer paths, eliminating stringly typed paths entirely.

Summary

A typed JSON Patch builder gives you a safer and more maintainable way to generate partial JSON updates in Rust. By modeling pointers, operations, and serialization explicitly, you reduce runtime mistakes and make patch generation easier to reason about.

The core idea is simple:

  • validate paths early
  • represent operations with Rust types
  • serialize with serde
  • keep the API ergonomic for real application code

That combination works well for APIs, sync engines, and any system that needs precise JSON mutations.

Learn more with useful resources