Generic data on typed struct

Table of Contents

First iteration

I started with something like this in my hot-path:

type UpdateType string

const (
    UpdateTypeA UpdateType = "A"
    UpdateTypeB UpdateType = "B"
    UpdateTypeC UpdateType = "C"
)

type UpdateAData struct {
    field int
}

type UpdateBData struct {
    field string
}

type UpdateCData struct {
    field float64
}

type Update struct {
    Type UpdateType
    Data json.RawMessage
}

An Update struct that holds a Type and some Data corresponding to the type, that is json encoded.

As it works, each time you receive an update first you (possibly) unmarshall into the Update struct, switch on the Type and then unmarshall the Data. Even if you don’t marshal the entire struct, for example by passing it around with channels, you still need to unmarshall the Data field every time.

Simulating thousands of iterations that move these packets around with different strategies, the json.Unmarshall method seemed to occupy a lot more CPU than the strategy logic itself.

Second iteration

In the second iteration I tried to get rid of json.Unmarshall by using generics.

First I introduced an interface constraint:

type UpdateData interface {
    UpdateAData | UpdateBData | UpdateCData
}

Then I made the update struct generic over the constraint:

type Update[D UpdateData] struct {
    Type UpdateType
    Data D
}

For this to work, I also needed to implement a new interface:

type TypedUpdate interface {
    Type() UpdateType
}

func (u Update[D]) Type() UpdateType {
    return u.Type
}

So i can then do stuff like:

func process(c chan TypedUpdate) {
    for u := <- c {
        _ = handle(u)
    }
}

func handle(update TypedUpdate) error {
    switch update.GetType() {
    case UpdateTypeA:
        up, ok := update.(Update[UpdateAData])
        if !ok {
            return fmt.Errorf("update data is not UpdateAData")
        }
        return handleA(up.Data)
    case UpdateTypeB:
        up, ok := update.(Update[UpdateBData])
        if !ok {
            return fmt.Errorf("update data is not UpdateBData")
        }
        return handleB(up.Data)
    case UpdateTypeC:
        up, ok := update.(Update[UpdateCData])
        if !ok {
            return fmt.Errorf("update data is not UpdateCData")
        }
        return handleC(up.Data)
    }
}

func handleA(data UpdateAData) error { /* ... */ }
func handleB(data UpdateBData) error { /* ... */ }
func handleC(data UpdateCData) error { /* ... */ }

Without the interface, both the handle function above and the chan TypedUpdate would have to be generic which is sometimes problematic because you eventually need to instantiate the generic type making it only handle one sub-type of Update[D].

After benchmarking this seems like a more performant solution, as we don’t have to unmarshall any JSON, but it’s still far from perfect. For example, as the handle method receives an interface you don’t have any compile-time guarantees that it will be an Update struct. You also don’t have any compile-time guarantees that an UpdateTypeC typed object will contain UpdateCData in it, so you’ll have to verify all this at runtime.

I’m not sure if it’s the best solution possible right now, but it works as intended as far as I tested it and seems a little nicer than encoding and decoding JSON.