Konrad Reiche Photos Talks About RSS

A generic protobuf reader with Go's type parameters

Writing a generic protobuf writer in Go is straightforward. We simply use proto.Marshal with the protobuf message because proto.Marshal expects the proto.Message interface, which all generated protobuf messages implement.

However, when it comes to reading serialized protobuf data into a specific Go type, historically, we had to specify the type explicitly:

var post pb.Post
if err := proto.Unmarshal(b, &post); err != nil {
	return nil, err
}

This approach is clear and explicit: what you see is what you get. But what if you need a more generic solution? You might encounter a scenario similar to mine: a cache abstraction designed to handle different kinds of protobuf messages generically. My initial attempt looked like this:

func Read[T proto.Message](b []byte) (T, error) {
	var msg T
	if err := proto.Unmarshal(b, msg); err != nil {
		return msg, err
	}
	return msg, nil
}

While this code compiles, it doesn’t work as intended in practice. Trying to use it, e.g. results in a compile-time error.

Read[pb.Post](b)

The error occurs because pb.Post does not satisfy the proto.Message interface, the method ProtoReflect has a pointer receiver.

Read[*pb.Post](b)

Changing the call to use *pb.Post compiles successfully, but unfortunately leads to a runtime panic. This happens because msg is instantiated as nil, and we pass a nil pointer to proto.Unmarshal, causing the panic.

Addressing the Pointer vs. Value Type Issue

The root issue here is that protobuf-generated types implement proto.Message with pointer receivers. Our generic function must explicitly account for both pointer and value type. How do we instruct the Go compiler to handle the pointer and value types simultaneously?

The solution involves introducing a new interface wrapper and using a second type parameter that references the first type parameter.

type Proto[T any] interface {
	proto.Message
	*T
}

func Read[T any, P Proto[T]](b []byte) (P, error) {
	var msg P = new(T)
	if err := proto.Unmarshal(b, msg); err != nil {
		return nil, err
	}
	return msg, nil
}

How it works:

We capture both the value and pointer type by defining the two type parameters. When calling the function, it’s sufficient to specify only the first type parameter, as the second type parameter is inferred.

post, err := Read[pb.Post](b)
if err != nil {
	return nil, err
}

Although this two-parameter generic definition might seem complex, it neatly addresses our specific use case. The same pattern also applies to Thrift—just replace proto.Message with thrift.TStruct.