An adapter maps a protobuf message to a native Elixir value and back — for
example google.protobuf.Timestamp to a timestamp tuple, or a custom message to
a domain value. Adapters are semantic conversions, distinct from the
structural representation options covered in
Decoding into structs. See
Representation vs adapters for the
distinction.
The adapter contract
An adapter is a PB.Adapter struct with two {module, function} MFAs, each
returning {:ok, value} | {:error, reason}:
adapter =
%PB.Adapter{
to_proto: {MyApp.TimestampCodec, :to_proto}, # app value -> proto-shaped map
from_proto: {MyApp.TimestampCodec, :from_proto}, # proto map -> app value
type: quote(do: DateTime.t()) # optional typespec, for docs
}Register it for a fully-qualified message name via the :projections option:
PB.compile(descriptor_set,
projections: [{:"google.protobuf.Timestamp", adapter: adapter}]
)Adapted message fields decode to the adapter's Elixir value and encode from that same value. The callbacks convert between the app value and the ordinary proto-shaped map; PB still owns wire encoding and decoding.
Invariants
Adapters are deliberately constrained so you cannot validate against a different shape than you encode against:
- Adapters live only on the schema provider — declared once via
PB.compile/2oruse PB.Schema. No per-call or field-level overrides. - Adapters are bidirectional, always — every adapter declares both
from_protoandto_proto. CEL field reads, encoding, and validation all reuse the sameto_prototransform.
Built-in well-known type adapters
PB.WellKnownTypes.projections/1 returns ready-to-use :projections entries for
groups of well-known types. They are opt-in — nothing is forced on a schema that
does not register them.
PB.compile(descriptor_set,
projections: PB.WellKnownTypes.projections([:time, :wrappers, :json])
)The groups are:
:time—google.protobuf.Timestamp→{:timestamp, unix_nanoseconds}andgoogle.protobuf.Duration→{:duration, nanoseconds}. Integer nanoseconds keep protobuf precision intact (seePB.WellKnownTypes).:wrappers—google.protobuf.Int32Valueand friends unwrap to their scalar.:json—google.protobuf.Value/Struct/ListValuemap to JSON-compatible Elixir terms.
If you want lossy but friendlier forms — e.g. DateTime instead of a nanosecond
tuple — write a custom adapter, as in the contract example above.
Adapters and validation
Because validation reads through the same adapters as encode, protovalidate rules
see the value you actually hand to PB.encode/4. CEL unwraps adapted messages
lazily and shallowly: the adapter's to_proto runs only when an expression
selects into that message. See Validation.