Table of contents
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.