Unleashing the power of Go: How to Unmarshal Dynamic JSON

ยท

5 min read

Unleashing the power of Go: How to Unmarshal Dynamic JSON

Today we are going to write our own custom json.Unmarshal() method. I find it very useful and can make our code more maintainable and readable if we can make good use of type alias and the json.Unmarshal method. Let's say we want to decode a JSON-encoded data take from a notification service where it sends us different events with different underlying data structure. Here is an example json of events:

{"resource_type":"payment","action":"confirmed","data":{"amount":100}}
{"resource_type":"customer","action":"created","data":{"name":"john"}}

We have some basic fields like resource_type and action that are the same for all events, but the type of data is different depending on the resource_type. For example, the payment resource has an amount field of type number, and this is obviously different from the other resource type, customer, where it has a name field of type string.

Built-in JSON Unmarshal Method

The json.Unmarshal() method in Golang is used to decode a JSON-encoded data structure into a Golang struct or map. It takes two parameters - a byte slice containing the JSON-encoded data, and a pointer to the struct or map that the decoded data will be stored in. How do we do that with the built-in method json.Unmarshal. Let's take a look an example below:

package main

import (
    "encoding/json"
    "fmt"
)

type Event struct {
    ResourceType string `json:"resource_type"`
    Action       string `json:"action"`
    Data         any    `json:"data"`
}

func main() {
    var jsonBlob = []byte(`[
    {"resource_type":"payment","action":"confirmed","data":{"amount":100}},
    {"resource_type":"customer","action":"created","data":{"name":"john"}}
]`)

    var events []Event
    err := json.Unmarshal(jsonBlob, &events)
    if err != nil {
        fmt.Println("error:", err)
    }
    fmt.Printf("%+v", events)
    // output: [{ResourceType:payment Action:confirmed Data:map[amount:100]} {ResourceType:customer Action:created Data:map[name:john]}]
}

As you can see from the example above, we set the Data field to type any as we don't know what type of data we will get from the notification service. When we let the program decode the data field, we get the value as map[string]interface{}. We may get away with this since we can still access the data in the map by key, depending on the resource type. However, this method has the disadvantage of making the code less readable and maintainable, because we don't know the structure of the data field. In addition, we cannot take advantage of the go package validator, which validates structures and individual fields based on tags. You can see more details about Validator.

We would need to know exactly what type of data we want go to decode, but we don't know that until go has received the data. Is there a way to decode the resource_type field first, before decoding everything, so that we know for sure what type of data we are decoding to? The answer is yes, but we will have to create our own custom UnmarshalJSON method. To see more details about UnmarshalJSON.

Unmarshal Dynamic Data

Here is the example for creating our own custom UnmarshalJSON method:

type Event struct {
    ResourceType string `json:"resource_type"`
    Action       string `json:"action"`
    Data         any    `json:"data"`
}

type Customer struct {
    Name string `json:"name"`
}

type Payment struct {
    Amount int `json:"amount"`
}

func (e *Event) UnmarshalJSON(data []byte) error {
    var inner struct {
        ResourceType string `json:"resource_type"`
    }
    if err := json.Unmarshal(data, &inner); err != nil {
        return err
    }

    switch inner.ResourceType {
    case "payment":
        e.Data = new(Payment)
    case "customer":
        e.Data = new(Customer)
    }

    type aka Event
    return json.Unmarshal(data, (*aka)(e))
}

In this example we define a Customer struct which has a field - Name and a Payment struct which has a field - Amount. We then create an inner struct to get the ResourceType in our custom UnmarshalJSON method, so that we can have a switch statement to decide which struct we want our go program to decode into depending on the resource type. Once we have assigned our data to the appropriate structure, we can call the json.Unmarshal() method to decode the JSON string into the receiver e. You may notice that we declare an alias type aka Event instead of using Event directly, this is because we are trying to avoid an infinite loop with a custom UnmarshalJSON method.

A type alias is a way of defining a new name for an existing type. However, it does not inherit the methods of the Event type, so we can get around that by using an alias.

Let's put them all together and see the result below:

package main

import (
    "encoding/json"
    "fmt"
)

type Event struct {
    ResourceType string `json:"resource_type"`
    Action       string `json:"action"`
    Data         any    `json:"data"`
}

func (e *Event) UnmarshalJSON(data []byte) error {
    var inner struct {
        ResourceType string `json:"resource_type"`
    }
    if err := json.Unmarshal(data, &inner); err != nil {
        return err
    }

    switch inner.ResourceType {
    case "payment":
        e.Data = new(Payment)
    case "customer":
        e.Data = new(Customer)
    }

    type eventAlias Event
    return json.Unmarshal(data, (*eventAlias)(e))
}

type Customer struct {
    Name string `json:"name"`
}

func (c *Customer) String() string {
    return fmt.Sprintf("{Name:%s}", c.Name)
}

type Payment struct {
    Amount int `json:"amount"`
}

func (p *Payment) String() string {
    return fmt.Sprintf("{Amount:%d}", p.Amount)
}

func main() {
    var jsonBlob = []byte(`[
    {"resource_type":"payment","action":"confirmed","data":{"amount":100}},
    {"resource_type":"customer","action":"created","data":{"name":"john"}}
]`)

    var events []Event
    err := json.Unmarshal(jsonBlob, &events)
    if err != nil {
        fmt.Println("error:", err)
    }
    fmt.Printf("%+v", events)
    // output: [{ResourceType:payment Action:confirmed Data:{Amount:100}} {ResourceType:customer Action:created Data:{Name:john}}]
}

Before we try out our new custom UnmarshalJSON method, we add an extra custom String method to these two types, Customer and Payment, so that our result can be printed more nicely. Moreover, we can be certain that they are decoded to corresponding types. Now, we can call the json.Unmarshal() method to decode the jsonBlob variable into events variable. Finally, we print out the events to the console and we get the whole event with our custom type instead of map[string]interface{}.

Conclusion

By writing our own custom UnmarshalJSON method, we have more granular control over how the data is decoded into our own struct, even though the data is dynamic. In addition, we can use the go package validator because we have defined our custom struct for each field and subfield.

We learn how to use aliases to prevent recursion calls to the custom method from causing panic, although the original design of Aliases is to make code more readable by giving a more descriptive name to a type, or to make it easier to refactor your code later. Please let me know what you think and feel free to leave a comment below :)

Hopefully you found something useful and can apply it to your work.

The source code for this tutorial is available here.

Thank you for reading.

Did you find this article valuable?

Support Ray Yang by becoming a sponsor. Any amount is appreciated!

ย