MarshalJSON on compound struct doesn't work as expected

Hi guys,

I’m working on an API that receives and sends JSON, nothing fancy here :slightly_smiling_face:
The first concern I had was that I needed to only work with UTC times and dates across the API, so any date/time received needed to be converted to UTC.

In order to make this work on already existing structs, I’ve implemented the MarshalJSON() method, and it works like a charm. You can see a simplified example below to illustrate with the Contact struct

package main

import (
	"encoding/json"
	"fmt"
	"time"
)

type Contact struct {
	FirstName string    `json:"first_name"`
	LastName  string    `json:"last_name"`
	CreatedAt time.Time `json:"created_at"`
}

func (c *Contact) MarshalJSON() ([]byte, error) {
	type ContactAlias Contact
	return json.Marshal(&struct {
		*ContactAlias
		CreatedAt time.Time `json:"created_at" sql:"created_at"`
	}{
		ContactAlias: (*ContactAlias)(c),
		CreatedAt:    c.CreatedAt.UTC(),
	})
}

func main() {
	contact := &Contact{FirstName: "John", LastName: "Doe", CreatedAt: time.Now()}
	s, err := json.Marshal(&contact)
	if err != nil {
		println(err.Error())
	}
	fmt.Printf("STEP 1)\n\n%s\n\n\n", s)
}

Then, I needed to add another field to my Contact struct that is Intro

package main

import (
	"encoding/json"
	"fmt"
	"time"
)

type Contact struct {
	FirstName string    `json:"first_name"`
	LastName  string    `json:"last_name"`
	CreatedAt time.Time `json:"created_at"`
}

// STEP-1
func (c *Contact) MarshalJSON() ([]byte, error) {
	type ContactAlias Contact
	return json.Marshal(&struct {
		*ContactAlias
		CreatedAt time.Time `json:"created_at" sql:"created_at"`
	}{
		ContactAlias: (*ContactAlias)(c),
		CreatedAt:    c.CreatedAt.UTC(),
	})
}

// STEP-2
type ContactWithIntro struct {
	Contact
	Intro string `json:"intro"`
}

func main() {
	contact := &Contact{FirstName: "John", LastName: "Doe", CreatedAt: time.Now()}
	s, err := json.Marshal(&contact)
	if err != nil {
		println(err.Error())
	}
	fmt.Printf("STEP 1)\n\n%s\n\n\n", s)

	intro := "Hello World!"
	contactWithIntro := ContactWithIntro{
		Contact: *contact,
		Intro:   intro,
	}

	fmt.Printf("STEP 2-a)\n\n%+v\n\n\n", contactWithIntro)

	s, err = json.Marshal(&contactWithIntro)
	if err != nil {
		println(err.Error())
	}
	fmt.Printf("STEP 2-b)\n\n%s\n", s)
}

As you can see, I can’t get the intro field in the ContactWithInfo JSON string :confused:
After a few tests, I figured out that the MarshalJSON is the source of evil, because if I remove it I can get the info field in the final JSON but then time is not UTC anymore …

The problem is that I don’t understand what’s happening under the hood.

Thank you for your support.

Below is the link to test the snippet:

output

STEP 1)

{"first_name":"John","last_name":"Doe","created_at":"2023-01-23T20:40:24.23Z"}

STEP 2-a)

{Contact:{FirstName:John LastName:Doe CreatedAt:2023-01-23 21:40:24.23 +0100 UTC+1 m=+0.000900097} Intro:Hello World!}

STEP 2-b)

{"first_name":"John","last_name":"Doe","created_at":"2023-01-23T20:40:24.23Z"}

Edit 24/01/2023 - undesired workaround

A solution to work with UTC time would be to create a custom type associated to a custom MarshalJSON method but this implies to cast the time value everywhere it’s assigned to the field struct. I would prefer avoid this solution.

package main

import (
    "encoding/json"
    "fmt"
    "time"
)

type Contact struct {
    FirstName string      `json:"first_name"`
    LastName  string      `json:"last_name"`
    CreatedAt DateTimeUTC `json:"created_at"`
}

type DateTimeUTC time.Time

func (c DateTimeUTC) MarshalJSON() ([]byte, error) {
    return json.Marshal(time.Time(c).UTC().Format(time.RFC3339))
}

func main() {
    t := DateTimeUTC(time.Now())
    contact := &Contact{FirstName: "John", LastName: "Doe", CreatedAt: t}
    s, err := json.Marshal(&contact)
    if err != nil {
        println(err.Error())
    }
    fmt.Printf("%s\n", s)
}

snippet here: Better Go Playground

I embedded the Contact type into ContactWithIntro , adding the MarshalJSON method to its method set, hence the custom MarshalJSON from the Contact struct is promoted to the outer struct, hence called when marshalling ContactWithIntro .

So, I’ll have to use the workaround mentioned in the Edit 24/01/2023 section, or define the custom marshalJSON on the outer struct: ContactWithIntro