November 13, 2022

TIL Using Custom Marshaling in Go

Not quite a Today I Learned, but today I did implement a custom marshaler for my project Homebox and thought I would share the process and why I think it can be a great tool for your API. First let’s dive into the requirements of the project.

Requirements

I’m building a home inventory system, one of the most requested features is the ability to generated predictable asset tags. The asset tags are effectively an auto incrementing field in the database.

You’ll often see these represented as 010-020 where you have a number represented as a string with a required amount of leading zeros to meet the length of the string. In this case, we want a string of length 6. This is a great visual representation, but what we really want on the backend is to just treat this like any other integer. That way we can leverage SQL and Go functionality that only works with integer types.

Building the Marshal/Unmarshal

First we need to declare a custom type alias to implement the json.Marshaler and json.Unmarshaler interfaces.

type AssetID int

Now we can implement the MarshalJSON and UnmarshalJSON receivers.

The MarshalJSON method is pretty straight forward, we just need to format the integer into a string with the correct length and then add the dashes. We then return the string as a byte slice wrapped in quotes.

func (aid AssetID) MarshalJSON() ([]byte, error) {
	aidStr := fmt.Sprintf("%06d", aid)
	aidStr = fmt.Sprintf("%s-%s", aidStr[:3], aidStr[3:])
	return []byte(fmt.Sprintf(`"%s"`, aidStr)), nil
}

The UnmarshalJSON method is a little more complicated. We need to remove the quotes and dashes from the string, then convert the string to an integer and assign it to the pointer receiver.

func (aid *AssetID) UnmarshalJSON(d []byte) error {
	d = bytes.Replace(d, []byte(`"`), []byte(``), -1)
	d = bytes.Replace(d, []byte(`-`), []byte(``), -1)

	aidInt, err := strconv.Atoi(string(d))
	if err != nil {
		return err
	}

	*aid = AssetID(aidInt)
	return nil
}

There’s a couple things you want to keep in mind when implementing this method

  • I’m using a pointer receiver for the UnmarshalJSON method. This is because we need to modify the value of the receiver, and we can’t do that with a value receiver.
  • I’m using the bytes.Replace method instead of the strings.Replace method. You first inclination may be to convert the byte slice to a string and then work with it there, but strings in Go are just pointers to an immutable byte array, so doing so would create a new string object every time you call strings.Replace. Does that matter? In most cases probably not, but I’d argue that if you’re already working with a byte slice, you might as well avoid the extra allocations and use the bytes package instead.
  • I’m not stripping the leading zeros from the string. strconv.Atoi will do that for us, so we don’t need to worry about it.
In Go, most API’s return slices of bytes, even if the underlying data is just a string. Take a look at the bytes package and get familiar with it’s methods. Using it over the strings package can improve perform and reduce unnecessary conversions between types.

Summary

With the implementation above we can now easily convert between our “display” format and the integer format we want to use in the database. This is a great example of how you can use custom marshalers to make your API more user friendly without sacrificing the underlying data type or over complicating your code.

Full Code

package repo

import (
	"bytes"
	"fmt"
	"strconv"
)

type AssetID int

func (aid AssetID) MarshalJSON() ([]byte, error) {
	aidStr := fmt.Sprintf("%06d", aid)
	aidStr = fmt.Sprintf("%s-%s", aidStr[:3], aidStr[3:])
	return []byte(fmt.Sprintf(`"%s"`, aidStr)), nil
}

func (aid *AssetID) UnmarshalJSON(d []byte) error {
	d = bytes.Replace(d, []byte(`"`), []byte(``), -1)
	d = bytes.Replace(d, []byte(`-`), []byte(``), -1)

	aidInt, err := strconv.Atoi(string(d))
	if err != nil {
		return err
	}

	*aid = AssetID(aidInt)
	return nil

}