How to custom marshal map keys in JSON

Viewed 4599

I can't understand a strange behavior of custom marshal int to string.

Here is an example:

package main

import (
    "encoding/json"
    "fmt"
)

type Int int

func (a Int) MarshalJSON() ([]byte, error) {
    test := a / 10
    return json.Marshal(fmt.Sprintf("%d-%d", a, test))
}

func main() {

    array := []Int{100, 200}
    arrayJson, _ := json.Marshal(array)
    fmt.Println("array", string(arrayJson))

    maps := map[Int]bool{
        100: true,
        200: true,
    }
    mapsJson, _ := json.Marshal(maps)
    fmt.Println("map wtf?", string(mapsJson))
    fmt.Println("map must be:", `{"100-10":true, "200-20":true}`)
}

The output is:

array ["100-10","200-20"]
map wtf? {"100":true,"200":true}
map must be: {"100-10":true, "200-20":true}

https://play.golang.org/p/iiUyL2Hc5h_P

What am I missing?

2 Answers

This is the expected outcome, which is documented at json.Marshal():

Map values encode as JSON objects. The map's key type must either be a string, an integer type, or implement encoding.TextMarshaler. The map keys are sorted and used as JSON object keys by applying the following rules, subject to the UTF-8 coercion described for string values above:

- string keys are used directly
- encoding.TextMarshalers are marshaled
- integer keys are converted to strings

Note that map keys are handled differently than values of properties because map keys in JSON are the property names which are always string values (while property values may be JSON text, number and boolean values).

As per the doc, if you want it to work for map keys as well, implement encoding.TextMarshaler:

func (a Int) MarshalText() (text []byte, err error) {
    test := a / 10
    return []byte(fmt.Sprintf("%d-%d", a, test)), nil
}

(Note that MarshalText() is ought to return "just" simple text, not JSON text, hence we omit JSON marshaling in it!)

With this, output will be (try it on the Go Playground):

array ["100-10","200-20"] <nil>
map wtf? {"100-10":true,"200-20":true} <nil>
map must be: {"100-10":true, "200-20":true}

Note that encoding.TextMarshaler is enough as that is also checked when marsaling as values, not just for map keys. So you don't have to implement both encoding.TextMarshaler and json.Marshaler.

If you do implement both, you can have different output when the value is marshaled as a "simple" value and as a map key because json.Marshaler takes precedence when generating a value:

func (a Int) MarshalJSON() ([]byte, error) {
    test := a / 100
    return json.Marshal(fmt.Sprintf("%d-%d", a, test))
}

func (a Int) MarshalText() (text []byte, err error) {
    test := a / 10
    return []byte(fmt.Sprintf("%d-%d", a, test)), nil
}

This time the output will be (try it on the Go Playground):

array ["100-1","200-2"] <nil>
map wtf? {"100-10":true,"200-20":true} <nil>
map must be: {"100-10":true, "200-20":true}

The accepted answer is great but I've had to re-search for this enough times that I wanted to put a complete answer regarding marshal/unmarshal with examples so next time I can just copy paste as a starting point :)

The things I often search for include:

  • encode custom type to sql database
  • json encode enum int as string
  • json encode map key but not value

In this example I make a custom Weekday type which matches time.Weekday int values but allows for the string value in request/response json and in the database

This same thing can be done with any int enum using iota to have the human readable value in json and database

Playground example with tests: https://go.dev/play/p/aUxxIJ6tY9K

The important bit is here though:

var (
    // read/write from/to json values
    _ json.Marshaler   = (*Weekday)(nil)
    _ json.Unmarshaler = (*Weekday)(nil)

    // read/write from/to json keys
    _ encoding.TextMarshaler   = (*Weekday)(nil)
    _ encoding.TextUnmarshaler = (*Weekday)(nil)

    // read/write from/to sql
    _ sql.Scanner   = (*Weekday)(nil)
    _ driver.Valuer = (*Weekday)(nil)
)

// MarshalJSON marshals the enum as a quoted json string
func (w Weekday) MarshalJSON() ([]byte, error) {
    return []byte(`"` + w.String() + `"`), nil
}

func (w Weekday) MarshalText() (text []byte, err error) {
    return []byte(w.String()), nil
}

func (w *Weekday) UnmarshalJSON(b []byte) error {
    return w.UnmarshalText(b)
}

func (w *Weekday) UnmarshalText(b []byte) error {
    var dayName string
    if err := json.Unmarshal(b, &dayName); err != nil {
        return err
    }

    d, err := ParseWeekday(dayName)
    if err != nil {
        return err
    }

    *w = d
    return nil
}

// Value is used for sql exec to persist this type as a string
func (w Weekday) Value() (driver.Value, error) {
    return w.String(), nil
}

// Scan implements sql.Scanner so that Scan will be scanned correctly from storage
func (w *Weekday) Scan(src interface{}) error {
    switch t := src.(type) {
    case int:
        *w = Weekday(t)
    case int64:
        *w = Weekday(int(t))
    case string:
        d, err := ParseWeekday(t)
        if err != nil {
            return err
        }
        *w = d
    case []byte:
        d, err := ParseWeekday(string(t))
        if err != nil {
            return err
        }
        *w = d
    default:
        return errors.New("Weekday.Scan requires a string or byte array")
    }
    return nil
}

Note that the var block simply forces you to implement the methods correctly else it won't compile.

Also note that if you exclude MarshalJSON then go will use MarshalText if it is there, so if you want only the key to have a custom marshal but to have the default behavior for the value then you should not use these methods on your main type, but instead have a wrapper type that you only use for map keys

type MyType struct{}
type MyTypeKey MyType
var (
    // read/write from/to json keys
    _ encoding.TextMarshaler   = (*MyTypeKey)(nil)
    _ encoding.TextUnmarshaler = (*MyTypeKey)(nil)
)
func (w MyTypeKey) MarshalText() (text []byte, err error) {
    return []byte(w.String()), nil
}

func (w *MyTypeKey) UnmarshalText(b []byte) error {
    *w = MyTypeKey(ParseMyType(string(b)))
    return nil
}

Feel free to improve this answer, I hope others find it helpful and I hope I can find it again next time I want this and search for it again myself :)

Related