string and []string can be the same thing…

As a maintainer of GoReleaser, I try to avoid breaking changes.

One thing that came up a couple of times is a configuration field that was a string, but now needs to be a []string.

I could of course just change it to []string, but would break anyone using it as a string still.

A better way to make this work is implementing yaml.Unmarshaler.

A quick example

Let’s say we have this structure:

type Config struct {
  MaybeStringArray string `yaml:"foo,omitempty"`
}

Which would allow YAML like this:

foo: "some value"

But now we want to allow the users to use it like this as well, while keeping compatibility with the previous version:

foo:
  - "some value"
  - "other value"

Making YAML do what we want

The yaml.v3 package has a very similar API to the standard library’s json package. Both have Marshal and Unmarshal, as well as a couple of similar interfaces - and we are about to use them!

First step would be to create a type alias for []string and change our Config type to use it:

type MaybeStringArray []string

type Config struct {
  Foo MaybeStringArray `yaml:"foo,omitempty"`
}

Then, we need to implement yaml.Unmarshaler:

// compile errors if interface is not implemented.
var _ yaml.Unmarshaler = &MaybeStringArray{}

// UnmarshalYAML implements yaml.Unmarshaler.
func (a *MaybeStringArray) UnmarshalYAML(value *yaml.Node) error {
	var slice []string
	// Try to decode into a []string, if it succeeds, we're done, return it!
	if err := value.Decode(&slice); err == nil {
		*a = slice
		return nil
	}

	// If not, try to decode into a string.
	var single string
	if err := value.Decode(&single); err != nil {
		return err
	}
	*a = []string{single}
	return nil
}

With that, both our YAML configuration in the beginning of the post can be parsed properly! Pretty neat, right?

What about marshaling?

Another case is that you might want to marshal it back to YAML, keeping the single string API if it’s a single item, or an array if it’s more than one.

To do that, we can implement yaml.Marshaler:

// compile errors if interface is not implemented.
var _ yaml.Marshaler = MaybeStringArray{}

// MarshalYAML implements yaml.Marshaler.
func (a MaybeStringArray) MarshalYAML() (interface{}, error) {
	switch len(a) {
	case 0:
		return nil, nil
	case 1:
		return a[0], nil
	default:
		return []string(a), nil
	}
}

Bonus: JSON

You can do the same thing with JSON, and the way to do it is basically the same as well:

var _ json.Unmarshaler = &MaybeStringArray{}
var _ json.Marshaler = MaybeStringArray{}

// UnmarshalJSON implements json.Unmarshaler.
func (a *MaybeStringArray) UnmarshalJSON(data []byte) error {
	var slice []string
	if err := json.Unmarshal(data, &slice); err == nil {
		*a = slice
		return nil
	}

	var single string
	if err := json.Unmarshal(data, &single); err != nil {
		return err
	}
	*a = []string{single}
	return nil
}

// MarshalJSON implements json.Marshaler.
func (a MaybeStringArray) MarshalJSON() ([]byte, error) {
	switch len(a) {
	case 0:
		return json.Marshal(nil)
	case 1:
		return json.Marshal(a[0])
	default:
		return json.Marshal([]string(a))
	}
}

See ya

There you have it!

I wrote this blog post because I can never find examples of this when I need to. Hopefully it helps you, and I’m sure it will help future me as well. :)

I made the entire source code available in a repository so you can play with it if you want!

Source