How protobuf-go-lite Handles Well-Known Types: Timestamp, Duration, Any, and Empty
protobuf-go-lite implements Timestamp, Duration, Any, and Empty as lightweight, reflection-free types with custom ProtoJSON marshaling, delegating to RFC-3339 and duration string formats while using an AnyTypeResolver for dynamic type resolution.
The aperturerobotics/protobuf-go-lite repository provides a minimal, high-performance alternative to the standard Go protobuf implementation. Understanding how it handles well-known types (WKTs) is essential for developers migrating from google.golang.org/protobuf or building systems that require deterministic JSON serialization without reflection overhead.
JSON Architecture for Well-Known Types
All four well-known types satisfy the json.Marshaler and json.Unmarshaler interfaces defined in json/marshal.go and json/unmarshal.go. This design allows the JSON plugin to treat them like standard messages while providing hand-crafted implementations that avoid reflection.
ProtoJSON Interface Contracts
The JSON layer defines two core interfaces that every well-known type implements:
type Marshaler interface {
MarshalProtoJSON(*MarshalState)
}
type Unmarshaler interface {
UnmarshalProtoJSON(*UnmarshalState)
}
These interfaces appear in json/marshal.go and json/unmarshal.go respectively. Unlike the standard library's json.Marshaler, these methods accept state objects that carry configuration and provide helper methods for writing JSON tokens.
Configuration and State Management
MarshalerConfig and UnmarshalerConfig structures carry an AnyTypeResolver that the Any type uses to look up concrete message constructors. The default configurations leave this resolver nil, causing operations that require type resolution to return ErrNoAnyTypeResolver. Users must explicitly provide a resolver when working with Any types.
Timestamp and Duration Implementation
The Timestamp and Duration types follow nearly identical implementation patterns, differing only in their specific time-formatting helpers.
Timestamp RFC-3339 Handling
The Timestamp type resides in types/known/timestamppb/timestamp.go. It provides construction helpers like ToTimestamp, FromUnixMilli, and New that wrap time.Time values.
For JSON serialization, MarshalProtoJSON delegates to MarshalState.WriteTime:
func (x *Timestamp) MarshalProtoJSON(s *json.MarshalState) {
if x == nil {
s.WriteNil()
return
}
s.WriteTime(x.AsTime())
}
The UnmarshalProtoJSON method uses ReadTime to parse RFC-3339 formatted strings back into the internal representation. These helpers live in json/state.go and handle the string formatting according to the protobuf JSON specification.
Duration String Format Support
The Duration type in types/known/durationpb/duration.go mirrors the Timestamp implementation but uses ReadDuration and WriteDuration helpers. These methods handle the canonical duration string format (e.g., "3.5s", "1h30m") specified in the protobuf standard.
Empty Type Handling
The Empty well-known type represents a message with no fields. In types/known/emptypb/empty.go, the JSON representation is simply the empty object {}.
The implementation is straightforward:
func (x *Empty) MarshalProtoJSON(s *json.MarshalState) {
s.WriteObjectStart()
s.WriteObjectEnd()
}
func (x *Empty) UnmarshalProtoJSON(s *json.UnmarshalState) {
if s.ReadNil() {
return
}
s.ReadObject(func(key string) {
s.SetErrorf("unexpected key %q in Empty", key)
})
*x = Empty{}
}
During unmarshaling, the implementation validates that no unexpected fields appear in the JSON object, maintaining strict compatibility with the protobuf specification.
Dynamic Type Resolution with Any
The Any type in types/known/anypb/any.go provides the most complex handling among well-known types. It stores arbitrary serialized messages along with a URL that identifies the message type, requiring dynamic type resolution during JSON operations.
The AnyTypeResolver Contract
Type resolution depends on the AnyTypeResolver interface defined in types/known/anypb/resolver/resolver.go:
type AnyTypeResolver interface {
FindMessageByURL(url string) (func() Message, error)
}
This interface maps type URLs (e.g., "type.googleapis.com/google.protobuf.Timestamp") to factory functions that create empty message instances. The resolver package provides helper constructors like NewFuncAnyTypeResolver for creating resolvers from simple functions.
Marshalling Any Types
The MarshalProtoJSON method in any.go writes the @type field containing the type URL, then resolves the concrete type using the resolver from MarshalState. After creating an instance and unpacking the embedded message, it marshals the inner message using its own MarshalProtoJSON method and writes it as the value field.
If the resolver is nil or cannot find the type, marshaling returns an error immediately, preventing silent data corruption.
Unmarshalling Any Types
The UnmarshalProtoJSON method first validates that the first field is @type, reads the type URL, and resolves the concrete type. For well-known types like Duration and Timestamp, it expects a specific structure with a value field. For other types, it creates a fresh instance and delegates to that type's UnmarshalProtoJSON method.
The implementation strictly validates that no extra fields remain after unmarshaling, ensuring round-trip fidelity.
Practical Code Examples
Working with Timestamp JSON
import (
"time"
"github.com/aperturerobotics/protobuf-go-lite/types/known/timestamppb"
"github.com/aperturerobotics/protobuf-go-lite/json"
)
func ExampleTimestamp() {
// Create a Timestamp from time.Now()
ts := timestamppb.ToTimestamp(time.Now())
// Marshal to JSON (RFC-3339 format)
data, _ := json.DefaultMarshalerConfig.Marshal(ts)
// Output: "2026-02-25T12:34:56Z"
// Unmarshal back
var ts2 timestamppb.Timestamp
_ = json.DefaultUnmarshalerConfig.Unmarshal(data, &ts2)
fmt.Println(ts2.AsTime().Equal(time.Now().Round(time.Second)))
}
Serializing Duration Values
import (
"github.com/aperturerobotics/protobuf-go-lite/types/known/durationpb"
"github.com/aperturerobotics/protobuf-go-lite/json"
)
func ExampleDuration() {
d := &durationpb.Duration{Seconds: 90, Nanos: 0} // 90 seconds
// Marshal to canonical duration string
b, _ := json.DefaultMarshalerConfig.Marshal(d) // → "90s"
var d2 durationpb.Duration
_ = json.DefaultUnmarshalerConfig.Unmarshal(b, &d2)
fmt.Println(d2.AsDuration()) // 1m30s
}
Handling Empty Messages
import (
"github.com/aperturerobotics/protobuf-go-lite/types/known/emptypb"
"github.com/aperturerobotics/protobuf-go-lite/json"
)
func ExampleEmpty() {
e := &emptypb.Empty{}
// JSON representation is always empty object
b, _ := json.DefaultMarshalerConfig.Marshal(e) // → `{}`
var e2 emptypb.Empty
_ = json.DefaultUnmarshalerConfig.Unmarshal(b, &e2)
fmt.Println(e2) // {}
}
Dynamic Any Types with Custom Resolver
import (
"github.com/aperturerobotics/protobuf-go-lite/types/known/anypb"
"github.com/aperturerobotics/protobuf-go-lite/types/known/timestamppb"
"github.com/aperturerobotics/protobuf-go-lite/json"
"github.com/aperturerobotics/protobuf-go-lite/types/known/anypb/resolver"
)
// Build a resolver that knows only Timestamp
func timestampResolver(url string) (func() protobuf_go_lite.Message, error) {
if url == "type.googleapis.com/google.protobuf.Timestamp" {
return func() protobuf_go_lite.Message { return ×tamppb.Timestamp{} }, nil
}
return nil, resolver.ErrNotFound
}
func ExampleAny() {
// Pack a Timestamp into an Any
ts := timestamppb.Now()
anyMsg, _ := anypb.New(ts, "type.googleapis.com/google.protobuf.Timestamp")
// Marshal with custom resolver
cfg := json.MarshalerConfig{
AnyTypeResolver: resolver.NewFuncAnyTypeResolver(timestampResolver),
}
b, _ := cfg.Marshal(anyMsg)
// → {"@type":"type.googleapis.com/google.protobuf.Timestamp","value":"2026-02-25T12:34:56Z"}
// Unmarshal back
var any2 anypb.Any
ucfg := json.UnmarshalerConfig{
AnyTypeResolver: resolver.NewFuncAnyTypeResolver(timestampResolver),
}
_ = ucfg.Unmarshal(b, &any2)
// Extract the Timestamp
var ts2 timestamppb.Timestamp
_ = any2.UnmarshalTo(&ts2, "type.googleapis.com/google.protobuf.Timestamp")
fmt.Println(ts2.AsTime())
}
Summary
- protobuf-go-lite implements well-known types as lightweight, reflection-free Go structs in the
types/known/package. - Timestamp and Duration use RFC-3339 and canonical duration string formats via
MarshalState.WriteTimeandWriteDurationhelpers. - Empty serializes to
{}and validates that no unexpected fields appear during unmarshaling. - Any requires an AnyTypeResolver to map type URLs to message constructors, enabling dynamic serialization of embedded messages without compile-time dependencies.
- All types implement
MarshalProtoJSONandUnmarshalProtoJSONdefined injson/marshal.goandjson/unmarshal.go, ensuring consistent JSON handling across the library.
Frequently Asked Questions
How does protobuf-go-lite differ from google.golang.org/protobuf for well-known types?
protobuf-go-lite provides hand-crafted implementations in types/known/timestamppb/timestamp.go and sibling files that avoid reflection and heavy code generation. While google.golang.org/protobuf relies on generated code and the protoreflect package, protobuf-go-lite uses direct MarshalProtoJSON and UnmarshalProtoJSON methods with lightweight state objects, resulting in smaller binary sizes and faster serialization.
Why does the Any type require a custom resolver in protobuf-go-lite?
The Any type stores arbitrary messages identified by a type URL, but the JSON implementation in types/known/anypb/any.go cannot know at compile time which concrete message types might be embedded. The AnyTypeResolver interface (defined in types/known/anypb/resolver/resolver.go) provides a factory function FindMessageByURL that returns a constructor for the specific type. Without this resolver, marshaling or unmarshaling Any returns ErrNoAnyTypeResolver.
Can I use standard library JSON marshaling with protobuf-go-lite well-known types?
No, you should use the protobuf-go-lite JSON runtime instead of encoding/json. The well-known types implement MarshalProtoJSON(*json.MarshalState) and UnmarshalProtoJSON(*json.UnmarshalState) rather than the standard json.Marshaler interface. Use json.DefaultMarshalerConfig.Marshal() or create a custom json.MarshalerConfig to serialize these types according to the protobuf JSON specification.
How does protobuf-go-lite handle invalid JSON during unmarshaling?
The UnmarshalProtoJSON implementations for well-known types perform strict validation. For example, Empty in types/known/emptypb/empty.go checks for unexpected fields and returns an error if any appear. Timestamp and Duration use ReadTime and ReadDuration helpers that validate RFC-3339 and duration string formats. The Any type validates that the first field is @type and that no extraneous fields remain after processing.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →