"Merge" fields two structs of same type

Foreword: The encoding/json package uses reflection (package reflect) to read/write values, including structs. Other libraries also using reflection (such as implementations of TOML and YAML) may operate in a similar (or even in the same way), and thus the principle presented here may apply to those libraries as well. You need to test it with the library you use.

For simplicity, the solution presented here uses the standard lib's encoding/json.


An elegant and "zero-effort" solution is to use the encoding/json package and unmarshal into a value of the "prepared", default configuration.

This handles everything you need:

  • missing values in config file: default applies
  • a value given in file overrides default config (whatever it was)
  • explicit overrides to zero values in the file takes precedence (overwrites non-zero default config)

To demonstrate, we'll use this config struct:

type Config struct {
    S1 string
    S2 string
    S3 string
    S4 string
    S5 string
}

And the default configuration:

var defConfig = &Config{
    S1: "", // Zero value
    S2: "", // Zero value
    S3: "abc",
    S4: "def",
    S5: "ghi",
}

And let's say the file contains the following configuration:

const fileContent = `{"S2":"file-s2","S3":"","S5":"file-s5"}`

The file config overrides S2, S3 and the S5 fields.

Code to load the configuration:

conf := new(Config) // New config
*conf = *defConfig  // Initialize with defaults

err := json.NewDecoder(strings.NewReader(fileContent)).Decode(&conf)
if err != nil {
    panic(err)
}

fmt.Printf("%+v", conf)

And the output (try it on the Go Playground):

&{S1: S2:file-s2 S3: S4:def S5:file-s5}

Analyzing the results:

  • S1 was zero in default, was missing from file, result is zero
  • S2 was zero in default, was given in file, result is the file value
  • S3 was given in config, was overriden to be zero in file, result is zero
  • S4 was given in config, was missing in file, result is the default value
  • S5 was given in config, was given in file, result is the file value

Reflection is going to make your code slow.

For this struct I would implement a straight Merge() method as:

type Config struct {
  path string
  id   string
  key  string
  addr string
  size uint64
}

func (c *Config) Merge(c2 Config) {
  if c.path == "" {
    c.path = c2.path
  }
  if c.id == "" {
    c.id = c2.id
  }
  if c.path == "" {
    c.path = c2.path
  }
  if c.addr == "" {
    c.addr = c2.addr
  }
  if c.size == 0 {
    c.size = c2.size
  }
}

It's almost same amount of code, fast and easy to understand.

You can cover this method with uni tests that uses reflection to make sure new fields did not get left behind.

That's the point of Go - you write more to get fast & easy to read code.

Also you may want to look into go generate that will generate the method for you from struct definition. Maybe there event something already implemented and available on GitHub? Here is an example of code that do something similar: https://github.com/matryer/moq

Also there are some packages on GitHub that I believe are doing what you want in runtime, for example: https://github.com/imdario/mergo


Another issue I see here is that checking for zero values may be tricky: what if the overriding struct intends to override with a zero value?

In case you cannot utilize encoding/json as pointed out by icza or other format encoders that behave similarly you could use two separate types.

type Config struct {
    Path string
    Id   string
    Key  string
    Addr string
    Size uint64
}

type ConfigParams struct {
    Path *string
    Id   *string
    Key  *string
    Addr *string
    Size *uint64
}

Now with a function like this:

func merge(conf *Config, params *ConfigParams)

You could check for non-nil fields in params and dereference the pointer to set the value in the corresponding fields in conf. This allows you to unset fields in conf with non-nil zero value fields in params.

Tags:

Struct

Go