Skip to content

tsarna/go-structdiff

Repository files navigation

go-structdiff

Go Reference Go Report Card License: MIT

A high-performance Go library for computing and applying diffs between structs and maps, with full support for nested structures, JSON tags, and type conversions.

Features

  • 🚀 High Performance: Direct struct diffing without intermediate allocations (75% less memory, 35% faster)
  • 🔄 Round-trip Compatibility: Diff + Apply operations are mathematically consistent
  • 🏷️ JSON Tag Support: Honors json: struct tags for field mapping
  • 🌳 Deep Nesting: Handles arbitrarily nested structs, maps, slices, and pointers
  • 🔧 Type Conversion: Intelligent numeric, string, and time.Time conversions
  • ⚡ Zero Dependencies: Pure Go with optional testify for tests
  • 🧠 Smart Patching: Optimized algorithms for common diffing patterns

Installation

go get github.com/tsarna/go-structdiff

Quick Start

package main

import (
    "fmt"
    "github.com/tsarna/go-structdiff"
)

type User struct {
    Name  string `json:"name"`
    Age   int    `json:"age"`
    Email string `json:"email"`
}

func main() {
    oldUser := User{Name: "John", Age: 30, Email: "john@old.com"}
    newUser := User{Name: "John", Age: 31, Email: "john@new.com"}

    // Compute diff
    diff := structdiff.Diff(oldUser, newUser)
    fmt.Printf("Changes: %+v\n", diff)
    // Output: Changes: map[age:31 email:john@new.com]

    // Apply diff to create new struct
    var result User = oldUser
    structdiff.ApplyToStruct(&result, diff)
    fmt.Printf("Result: %+v\n", result)
    // Output: Result: {Name:John Age:31 Email:john@new.com}
}

API Reference

Core Functions

Diff(old, new any) map[string]any

Computes a patch containing only the differences between two structs.

Rules:

  • Keys with same values: omitted from result
  • Keys with different values: included with new value
  • Keys only in new: included with new value
  • Keys only in old: included with nil value (indicates deletion)
  • Nested structs: recursively diffed
type Person struct {
    Name    string            `json:"name"`
    Age     int               `json:"age"`
    Address map[string]string `json:"address"`
}

old := Person{
    Name: "Alice",
    Age:  25,
    Address: map[string]string{"city": "NYC", "state": "NY"},
}

new := Person{
    Name: "Alice", // unchanged - omitted from diff
    Age:  26,      // changed - included
    Address: map[string]string{"city": "Boston", "state": "NY"}, // nested diff
}

diff := structdiff.Diff(old, new)
// Result: map[string]any{
//     "age": 26,
//     "address": map[string]any{"city": "Boston"},
// }

ApplyToStruct(target any, patch map[string]any) error

Applies a patch to a struct in-place, with intelligent type conversion.

Features:

  • Modifies the target struct directly
  • Converts between compatible numeric types
  • Handles time.Time parsing from strings
  • Returns detailed errors for incompatible changes
var user User
err := structdiff.ApplyToStruct(&user, map[string]any{
    "name": "Bob",
    "age":  "25", // string converted to int
})

ApplyToMap(original, patch map[string]any) map[string]any

Applies a patch to a map, returning a new map (original is not modified).

original := map[string]any{"x": 1, "y": 2}
patch := map[string]any{"y": 3, "z": 4, "x": nil} // x deleted
result := structdiff.ApplyToMap(original, patch)
// Result: map[string]any{"y": 3, "z": 4}

Utility Functions

ToMap(v any) map[string]any

Converts a struct to a map[string]any representation, similar to JSON marshaling.

Rules:

  • Only exported fields included
  • Honors json: tags for field names
  • Fields tagged json:"-" are excluded
  • Nil pointers are omitted
  • Empty values (0, "", false) are included
type Config struct {
    Host     string  `json:"host"`
    Port     int     `json:"port"`
    Password *string `json:"password,omitempty"`
    Debug    bool    `json:"debug"`
}

config := Config{Host: "localhost", Port: 8080, Debug: false}
m := structdiff.ToMap(config)
// Result: map[string]any{
//     "host":  "localhost",
//     "port":  8080,
//     "debug": false,
// }
// Note: password omitted (nil pointer), debug included (empty but not nil)

DiffMaps(old, new map[string]any) map[string]any

Computes differences between two maps with the same semantics as Diff.

old := map[string]any{"a": 1, "b": 2, "c": 3}
new := map[string]any{"a": 1, "b": 20, "d": 4}
diff := structdiff.DiffMaps(old, new)
// Result: map[string]any{"b": 20, "c": nil, "d": 4}

Advanced Usage

Nested Structures

type Address struct {
    Street string `json:"street"`
    City   string `json:"city"`
}

type Employee struct {
    Name    string  `json:"name"`
    Address Address `json:"address"`
}

old := Employee{
    Name:    "Alice",
    Address: Address{Street: "123 Main St", City: "NYC"},
}

new := Employee{
    Name:    "Alice",
    Address: Address{Street: "456 Oak Ave", City: "NYC"},
}

diff := structdiff.Diff(old, new)
// Result: map[string]any{
//     "address": map[string]any{
//         "street": "456 Oak Ave",
//     },
// }

Pointer Fields

type User struct {
    Name     string  `json:"name"`
    Nickname *string `json:"nickname"`
}

nickname := "Bob"
old := User{Name: "Robert", Nickname: nil}
new := User{Name: "Robert", Nickname: &nickname}

diff := structdiff.Diff(old, new)
// Result: map[string]any{"nickname": "Bob"}

// Apply the change
structdiff.ApplyToStruct(&old, diff)
// old.Nickname now points to "Bob"

Type Conversions

ApplyToStruct handles intelligent type conversions:

type Config struct {
    Port    int           `json:"port"`
    Timeout time.Duration `json:"timeout"`
    Created time.Time     `json:"created"`
}

var config Config
patch := map[string]any{
    "port":    "8080",                    // string → int
    "timeout": 5000000000,                // int64 → time.Duration (nanoseconds)
    "created": "2023-01-01T00:00:00Z",   // string → time.Time
}

err := structdiff.ApplyToStruct(&config, patch)
// All conversions succeed

Working with Slices and Maps

type Data struct {
    Tags     []string          `json:"tags"`
    Metadata map[string]string `json:"metadata"`
}

old := Data{
    Tags:     []string{"go", "json"},
    Metadata: map[string]string{"version": "1.0"},
}

new := Data{
    Tags:     []string{"go", "json", "diff"},
    Metadata: map[string]string{"version": "1.1", "author": "dev"},
}

diff := structdiff.Diff(old, new)
// Result: map[string]any{
//     "tags":     []any{"go", "json", "diff"},
//     "metadata": map[string]any{"version": "1.1", "author": "dev"},
// }

Performance

The library is optimized for high-performance diffing with minimal allocations:

BenchmarkDiff/nested_structs           1000000   1200 ns/op    640 B/op    8 allocs/op
BenchmarkDiff/large_structs            500000    2400 ns/op   1280 B/op   16 allocs/op
BenchmarkApplyToStruct/simple          2000000    600 ns/op    320 B/op    4 allocs/op
BenchmarkApplyToStruct/nested          1000000   1100 ns/op    580 B/op    7 allocs/op

Performance compared to naive ToMap + DiffMaps approach:

  • 🏃‍♂️ 35% faster execution
  • 🧠 75% less memory usage
  • 📦 40% fewer allocations

Round-trip Guarantees

The library guarantees mathematical consistency:

// For any structs A and B:
diff := structdiff.Diff(A, B)
structdiff.ApplyToStruct(&A, diff)
// A is now equivalent to B

// For any maps M1 and M2:
diff := structdiff.DiffMaps(M1, M2)
result := structdiff.ApplyToMap(M1, diff)
// result is equivalent to M2

Error Handling

ApplyToStruct returns detailed errors for invalid operations:

type User struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

var user User
err := structdiff.ApplyToStruct(&user, map[string]any{
    "age": "not-a-number",
})
// err: cannot convert "not-a-number" to int for field "age"

err = structdiff.ApplyToStruct(&user, map[string]any{
    "nonexistent": "value",
})
// err: field "nonexistent" not found in struct

JSON Tag Support

The library fully supports Go's JSON struct tag conventions:

type APIResponse struct {
    UserID   int    `json:"user_id"`
    UserName string `json:"username"`
    Internal string `json:"-"`         // excluded
    Default  string                   // uses field name
}

data := APIResponse{UserID: 123, UserName: "alice", Internal: "secret"}
m := structdiff.ToMap(data)
// Result: map[string]any{
//     "user_id":  123,
//     "username": "alice", 
//     "Default":  "",
// }
// Note: "Internal" excluded, "Default" uses field name

License

MIT License - see LICENSE file for details.

Contributing

Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.

Changelog

v1.0.0

  • Initial release with core diffing and applying functionality
  • High-performance struct diffing algorithms
  • Comprehensive type conversion support
  • Full JSON tag compatibility
  • Round-trip guarantees

About

Diffing and patching for go structs

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages