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:
T
is your concrete Protobuf type (e.g.,pb.Post
).P
is constrained to be a pointer toT
(*pb.Post
) and aproto.Message
.new(T)
creates a non-nil pointer to a zero-initializedT
, avoiding the nil panic.- Declaring
var msg P
ensures the compiler treatsmsg
as typeP
(not just*T
).
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
.