Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Type Extensions

Type extensions allow you to define custom serialization logic for types that aren't directly annotated with protobuf pragmas. This is useful when you want to serialize external types or types with custom encoding requirements.

When to Use Type Extensions

Use type extensions when:

  • You need to serialize types from external libraries
  • You want custom encoding logic (e.g., compression, encryption)
  • You're working with types that can't be annotated directly
  • You need to serialize wrapper types

Basic Structure

A type extension consists of three procedures:

  1. computeFieldSize—calculates the encoded size
  2. writeField—encodes the value
  3. readFieldInto—decodes the value

Simple Example

Let's create a custom type and make it serializable:

# Custom wrapper type (not annotated with proto2/proto3)
type
  IntWrapper = object
    value: int32

Now we define a message that uses this custom type:

# Message that uses the custom type
type
  Container {.proto3.} = object
    name {.fieldNumber: 1.}: string
    data {.fieldNumber: 2, ext.}: IntWrapper

To make IntWrapper serializable, we need to implement the three required procedures:

# Define type extension for IntWrapper
Protobuf.extensionDefaults(IntWrapper, pint32, defaultSeq = true)

func computeFieldSize(
    field: int,
    value: IntWrapper,
    ProtoType: type ProtobufExt,
    skipDefault: static bool
): int =
  computeFieldSize(field, value.value, pint32, skipDefault)

proc writeField(
    stream: OutputStream,
    field: int,
    value: IntWrapper,
    ProtoType: type ProtobufExt,
    skipDefault: static bool = false
) {.raises: [IOError].} =
  writeField(stream, field, value.value, pint32, skipDefault)

proc readFieldInto(
    stream: InputStream,
    value: var IntWrapper,
    header: FieldHeader,
    ProtoType: type ProtobufExt
): bool {.raises: [SerializationError, IOError].} =
  readFieldInto(stream, value.value, header, pint32)

Using the Extension

Now you can use the custom type in your protobuf messages:

let container = Container(
  name: "Test",
  data: IntWrapper(value: 42)
)

let encoded = Protobuf.encode(container)
let decoded = Protobuf.decode(encoded, Container)

assert decoded.data.value == 42

Extension with Sequences

The extensionDefaults macro can generate default handlers for sequences:

import protobuf_serialization

type
  CustomType = object
    value: string

# Generate defaults for seq[CustomType]
Protobuf.extensionDefaults(CustomType, pstring, defaultSeq = true)

func computeFieldSize(
    field: int,
    value: CustomType,
    ProtoType: type ProtobufExt,
    skipDefault: static bool
): int =
  computeFieldSize(field, value.value, pstring, skipDefault)

proc writeField(
    stream: OutputStream,
    field: int,
    value: CustomType,
    ProtoType: type ProtobufExt,
    skipDefault: static bool = false
) {.raises: [IOError].} =
  writeField(stream, field, value.value, pstring, skipDefault)

proc readFieldInto(
    stream: InputStream,
    value: var CustomType,
    header: FieldHeader,
    ProtoType: type ProtobufExt
): bool {.raises: [SerializationError, IOError].} =
  readFieldInto(stream, value.value, header, pstring)

# Now you can use seq[CustomType]
type
  Container {.proto3.} = object
    items {.fieldNumber: 1, ext.}: seq[CustomType]

let container = Container(
  items: @[
    CustomType(value: "first"),
    CustomType(value: "second"),
    CustomType(value: "third")
  ]
)

let encoded = Protobuf.encode(container)
let decoded = Protobuf.decode(encoded, Container)

assert decoded.items.len == 3
assert decoded.items[0].value == "first"
assert decoded.items[1].value == "second"
assert decoded.items[2].value == "third"

echo "Extension with sequences example passed!"

Complete Example

Here's a complete example with a custom timestamp type:

import protobuf_serialization

# Custom timestamp type
type
  Timestamp = object
    seconds: int64

# Message using the custom type
type
  Event {.proto3.} = object
    name {.fieldNumber: 1.}: string
    timestamp {.fieldNumber: 2, ext.}: Timestamp

# Type extension for Timestamp
Protobuf.extensionDefaults(Timestamp, pint64, defaultSeq = false)

func computeFieldSize(
    field: int,
    value: Timestamp,
    ProtoType: type ProtobufExt,
    skipDefault: static bool
): int =
  computeFieldSize(field, value.seconds, pint64, skipDefault)

proc writeField(
    stream: OutputStream,
    field: int,
    value: Timestamp,
    ProtoType: type ProtobufExt,
    skipDefault: static bool = false
) {.raises: [IOError].} =
  writeField(stream, field, value.seconds, pint64, skipDefault)

proc readFieldInto(
    stream: InputStream,
    value: var Timestamp,
    header: FieldHeader,
    ProtoType: type ProtobufExt
): bool {.raises: [SerializationError, IOError].} =
  readFieldInto(stream, value.seconds, header, pint64)

# Usage
let event = Event(
  name: "UserLogin",
  timestamp: Timestamp(seconds: 1234567890'i64)
)

let encoded = Protobuf.encode(event)
let decoded = Protobuf.decode(encoded, Event)

assert decoded.timestamp.seconds == 1234567890

echo "Complete type extension example passed!"

Key Points

  • Mark fields using extensions with {.ext.} pragma
  • The extended type itself should NOT be annotated with {.proto2.} or {.proto3.}
  • Use extensionDefaults to generate default sequence handlers
  • Implement all three procedures: computeFieldSize, writeField, readFieldInto
  • Extensions work with both single values and sequences

Next Steps