Skip to content

Marshaling union as JSON

MkUnion provides you with utility functions that allow you to marshal and unmarshal union types to JSON, reducing the burden of writing custom marshaling and unmarshaling functions for union types.

  • shared.JSONMarshal[A any](in A) ([]byte, error)
  • shared.JSONUnmarshal[A any](data []byte) (A, error)

Below is an example of how to use these functions and how the output JSON looks like.

example/tree_json_test.go
import (
    "github.com/widmogrod/mkunion/x/shared"
)


func Example_treeJson() {
    tree := &Branch[int]{
        L: &Leaf[int]{Value: 1},
        R: &Branch[int]{
            L: &Branch[int]{
                L: &Leaf[int]{Value: 2},
                R: &Leaf[int]{Value: 3},
            },
            R: &Leaf[int]{Value: 4},
        },
    }

    json, _ := shared.JSONMarshal[Tree[int]](tree)
    result, _ := shared.JSONUnmarshal[Tree[int]](json)

    fmt.Println(string(json))
    if diff := cmp.Diff(tree, result); diff != "" {
        fmt.Println("expected tree and result to be equal, but got diff:", diff)
    }
    //Output: {"$type":"example.Branch","example.Branch":{"L":{"$type":"example.Leaf","example.Leaf":{"Value":1}},"R":{"$type":"example.Branch","example.Branch":{"L":{"$type":"example.Branch","example.Branch":{"L":{"$type":"example.Leaf","example.Leaf":{"Value":2}},"R":{"$type":"example.Leaf","example.Leaf":{"Value":3}}}},"R":{"$type":"example.Leaf","example.Leaf":{"Value":4}}}}}}
}

Formatted JSON output of the example above:

{
  "$type": "example.Branch",
  "example.Branch": {
    "L": {
      "$type": "example.Leaf",
      "example.Leaf": {
        "Value": 1
      }
    },
    "R": {
      "$type": "example.Branch",
      "example.Branch": {
        "L": {
          "$type": "example.Branch",
          "example.Branch": {
            "L": {
              "$type": "example.Leaf",
              "example.Leaf": {
                "Value": 2
              }
            },
            "R": {
              "$type": "example.Leaf",
              "example.Leaf": {
                "Value": 3
              }
            }
          }
        },
        "R": {
          "$type": "example.Leaf",
          "example.Leaf": {
            "Value": 4
          }
        }
      }
    }
  }
}

There are a few things that you can notice in this example:

  • Each union type has a discriminator field, $type, which holds the type name, and a corresponding key with the name of the type, which holds the value of the union variant.

    • This is an opinionated approach, and the library doesn't allow it to be changed. I was experimenting with making this behavior customizable, but it makes the code and API more complex, and I prefer to keep it simple, thereby increasing interoperability between different libraries and applications.
  • Recursive union types are supported and are marshaled as nested JSON objects.

  • $type doesn't have to have the full package import name, nor type parameter, mostly because in shared.JSONUnmarshal[Tree[int]](json) you hint that your code accepts Tree[int].

    • I'm considering adding explicit type discriminators like example.Branch[int] or example.Leaf[int]. It could increase type strictness on the client side, but it makes generating TypeScript types more complex, and I'm not sure if it's worth it.
  • It's not shown in this example, but you can also reference types and union types from other packages, and serialization will work as expected.

Struct fields and json tags

Generated serialization follows the same rules as the standard library encoding/json:

type User struct {
    ID        string  `json:"id"`             // renamed to "id"
    Password  string  `json:"-"`              // never serialized
    Nickname  string  `json:"nick,omitempty"` // dropped when empty
    Age       int     `json:",omitempty"`     // keeps name "Age", dropped when 0
    Referrer  *User   `json:"referrer"`       // nil is serialized as null
}
  • Fields appear in JSON in declaration order, exactly like encoding/json.
  • json:"-" skips the field on both marshal and unmarshal.
  • omitempty drops the field when the value is empty: false, 0, "", a nil pointer, or an empty slice or map.
  • A nil pointer field without omitempty is serialized as null.

This means you can move a struct between plain encoding/json and mkunion serde without changing the JSON on the wire.

Next steps