
Building a Typed JSON Patch Builder in Rust
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 pathremove: delete a value at a pathreplace: update a value at a pathmove: relocate a value from one path to anothercopy: duplicate a value from one path to anothertest: assert that a path contains an expected value
A raw serde_json::Value approach works, but it is easy to make mistakes:
- forgetting the
opfield - using the wrong field for an operation
- building invalid JSON Pointer paths
- mixing up
fromandpath - 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:
- update the display name
- update the timezone
- 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
| Practice | Why it helps |
|---|---|
| Validate pointers at construction time | Prevents malformed patch paths from reaching runtime logic |
Use serde for serialization | Keeps the wire format standard and predictable |
| Model operations as an enum | Makes invalid operation combinations impossible |
| Prefer segment-based path builders | Avoids manual escaping mistakes |
Use test for optimistic concurrency | Reduces 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
PatchDocumentwrapper aroundVec<PatchOperation> - support strongly typed domain paths like
UserPath::ProfileDisplayName - integrate with
serde_json::Mapfor 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.
