Adapters and well-known types

Copy Markdown View Source

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/2 or use PB.Schema. No per-call or field-level overrides.
  • Adapters are bidirectional, always — every adapter declares both from_proto and to_proto. CEL field reads, encoding, and validation all reuse the same to_proto transform.

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:

  • :timegoogle.protobuf.Timestamp{:timestamp, unix_nanoseconds} and google.protobuf.Duration{:duration, nanoseconds}. Integer nanoseconds keep protobuf precision intact (see PB.WellKnownTypes).
  • :wrappersgoogle.protobuf.Int32Value and friends unwrap to their scalar.
  • :jsongoogle.protobuf.Value/Struct/ListValue map 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.