proto2type

July 5, 2026 · View on GitHub

CI Go Report Card

A protoc/buf plugin that generates native language types, storage structs, and bidirectional converters from Protocol Buffer definitions.

Why this exists

Every service that uses Protocol Buffers hits the same 3-layer problem:

Proto messages  ←→  Domain types  ←→  Storage structs
(wire format)       (business logic)   (database layer)

You define your data once in .proto files, then maintain parallel structs by hand — domain types with json:"" tags, Firestore types with firestore:"" tags, MongoDB types with bson:"" tags — plus the converter boilerplate between them. Fields drift. Tags get stale. A new field in the proto gets added to the domain struct but someone forgets the storage struct. Bugs compound silently.

proto2type eliminates this. Define your data once in proto. The plugin generates all three layers — domain types, storage structs, and converters — from a single source of truth.

Features

  • 🏗️ Domain types — clean native structs with json:"" tags, time.Time instead of timestamppb.Timestamp
  • 🔥 Firestore backendfirestore:"" tags, serverTimestamp sentinel, document ID exclusion
  • 🍃 MongoDB backendbson:"" tags, _id handling, ,inline support
  • 🔄 Bidirectional convertersToProto() / FromProto(), ToDomain() / FromDomain() on every struct
  • 🎯 Field mask helpersApplyFieldMask() for partial updates
  • 📋 Custom proto optionsdocument_id, server_timestamp, skip, omitempty, inline, name
  • 🗄️ SQLite backend (Rust)Row structs with to_domain() / from_domain(), JSON-serialised nested fields
  • 🔌 Works without a database — generate domain types only, no backend required
  • 🐍 Python/Pydantic backend — Pydantic BaseModel classes with Field() validation, google.api.field_behavior and buf/validate support
  • Validationbuf.validate constraint checking: Rust via validator crate, Kotlin via native validate(), Python via Pydantic Field()
  • 🌐 Multi-language — Go, Rust, Python, and Kotlin supported; TypeScript planned

Install

go install github.com/protocgen/proto2type@latest

This installs the protoc-gen-proto2type binary.

Usage

With buf

Domain types only (no backend):

# buf.gen.yaml
version: v2
plugins:
  - local: protoc-gen-proto2type
    out: gen/go
    opt:
      - lang=go

Domain + Firestore storage:

# buf.gen.yaml
version: v2
plugins:
  - local: protoc-gen-proto2type
    out: gen/go
    opt:
      - lang=go
      - backend=firestore

Storage only (skip domain types):

# buf.gen.yaml
version: v2
plugins:
  - local: protoc-gen-proto2type
    out: gen/go
    opt:
      - lang=go
      - domain=false
      - backend=mongo

Then run:

buf generate

Rust

Domain types (serde-annotated structs):

# buf.gen.rust.yaml
version: v2
plugins:
  - local: protoc-gen-proto2type
    out: gen/rust
    opt:
      - lang=rust

Domain + SQLite storage:

# buf.gen.rust.yaml
version: v2
plugins:
  # Domain types
  - local: protoc-gen-proto2type
    out: gen/rust
    opt:
      - lang=rust

  # SQLite Row structs
  - local: protoc-gen-proto2type
    out: gen/rust
    opt:
      - lang=rust
      - backend=sqlite
      - domain=false

Python

Pydantic models (absorbs proto2pydantic):

# buf.gen.yaml
version: v2
plugins:
  - local: protoc-gen-proto2type
    out: gen/python
    opt:
      - lang=python

A2A preset (camelCase aliases + raw enum names for A2A/ProtoJSON compatibility):

# buf.gen.yaml
version: v2
plugins:
  - local: protoc-gen-proto2type
    out: gen/python
    opt:
      - lang=python
      - preset=a2a

Note: The standalone proto2pydantic tool has been absorbed into proto2type. Use lang=python going forward.

Kotlin

Serializable data classes (kotlinx.serialization + kotlinx.datetime):

# buf.gen.kotlin.yaml
version: v2
plugins:
  - local: protoc-gen-proto2type
    out: gen/kotlin
    opt:
      - lang=kotlin
      - validate=true

Generates @Serializable data classes with proper WKT mappings, sealed class oneofs, and — when validate=true — native validate() / validateOrThrow() extension functions from buf.validate constraints.

Validation

When validate=true (or a language-specific strategy), proto2type reads buf.validate constraints from your protos and generates native validation code.

Given this proto:

syntax = "proto3";
package test.v1;

import "buf/validate/validate.proto";

message User {
  string email = 1 [(buf.validate.field).string.email = true];
  string display_name = 2 [(buf.validate.field).string.min_len = 1];
  int32 age = 3 [(buf.validate.field).int32 = { gte: 0, lte: 150 }];
}

Kotlin (validate=true) generates:

fun User.validate(): List<String> {
    val errors = mutableListOf<String>()
    if (email.isNotEmpty() && !email.matches(Regex(...))) errors.add("email must be a valid email")
    if (displayName.length < 1) errors.add("display_name must be at least 1 characters")
    if (age < 0) errors.add("age must be >= 0")
    if (age > 150) errors.add("age must be <= 150")
    return errors
}

fun User.validateOrThrow() {
    val errors = validate()
    if (errors.isNotEmpty()) throw IllegalArgumentException(errors.joinToString("; "))
}

Rust (validate=true) generates:

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Validate)]
pub struct User {
    #[validate(email)]
    pub email: String,
    #[validate(length(min = 1))]
    pub display_name: String,
    #[validate(range(min = 0, max = 150))]
    pub age: i32,
}

Python maps constraints automatically to Pydantic Field() kwargs (no validate flag needed):

class User(BaseModel):
    email: str = Field(..., pattern=r"^[^@]+@[^@]+$")
    display_name: str = Field(..., min_length=1)
    age: int = Field(..., ge=0, le=150)

Field Behavior & Validation

proto2type reads google.api.field_behavior annotations and buf/validate constraints from your protos and maps them to Pydantic Field() kwargs:

Proto AnnotationPydantic Effect
REQUIRED (google.api.field_behavior)Field(...) — no default, field is mandatory
OUTPUT_ONLY (google.api.field_behavior)Field(exclude=True) — excluded from serialization
buf/validate string rulesField(min_length=..., max_length=..., pattern=...)
buf/validate numeric rulesField(ge=..., le=..., gt=..., lt=...)

With protoc

protoc --proto2type_out=./gen/go \
       --proto2type_opt=backend=firestore \
       your_service.proto

Options

All options are passed via --proto2type_opt= (protoc) or opt: (buf).

See CONFIG.md for the full reference, including proto-level annotation options.

OptionDefaultDescription
langgoTarget language (go, rust, python, kotlin, typescript)
backend(none)Storage backend (firestore, mongo, sqlite, dynamodb, datastore, spanner)
domaintrueGenerate domain types + proto converters
output_file(auto)Override output filename
enum_as_stringfalseStore enums as string names instead of int32
omitempty_defaulttrueDefault omitempty for optional / zero-value fields
validate""Validation strategy from buf.validate constraints (true, validator, native)

Example

Given this proto:

// catalog.proto
syntax = "proto3";
package test.v1;

import "google/protobuf/timestamp.proto";

message ModelCatalogEntry {
  string model_id = 1;
  string provider = 2;
  string display_name = 3;
  double input_per_million = 4;
  double output_per_million = 5;
  bool enabled = 6;
  string category = 7;
  int64 context_window = 8;
  double discount_percent = 9;
  repeated string aliases = 12;
  string provider_model_id = 14;
  google.protobuf.Timestamp created_at = 13;
  google.protobuf.Timestamp updated_at = 15;
  string notes = 16;
  string region = 17;
}

Generated domain struct (catalog.type.go)

// Code generated by proto2type. DO NOT EDIT.
package catalog

import "time"

type ModelCatalogEntry struct {
	ModelID          string    `json:"model_id"`
	Provider         string    `json:"provider"`
	DisplayName      string    `json:"display_name"`
	InputPerMillion  float64   `json:"input_per_million"`
	OutputPerMillion float64   `json:"output_per_million"`
	Enabled          bool      `json:"enabled"`
	Category         string    `json:"category"`
	ContextWindow    int64     `json:"context_window"`
	DiscountPercent  float64   `json:"discount_percent"`
	Aliases          []string  `json:"aliases,omitempty"`
	ProviderModelID  string    `json:"provider_model_id"`
	CreatedAt        time.Time `json:"created_at,omitempty"`
	UpdatedAt        time.Time `json:"updated_at,omitempty"`
	Notes            string    `json:"notes"`
	Region           string    `json:"region"`
}

func (d *ModelCatalogEntry) ToProto() *catalogpb.ModelCatalogEntry { ... }
func (d *ModelCatalogEntry) FromProto(pb *catalogpb.ModelCatalogEntry) { ... }

Generated Firestore struct (catalog_firestore.type.go)

// Code generated by proto2type. DO NOT EDIT.
// backend: firestore
package catalog

import "time"

type ModelCatalogEntryFirestore struct {
	ModelID          string    `firestore:"model_id"`
	Provider         string    `firestore:"provider"`
	DisplayName      string    `firestore:"display_name"`
	InputPerMillion  float64   `firestore:"input_per_million"`
	OutputPerMillion float64   `firestore:"output_per_million"`
	Enabled          bool      `firestore:"enabled"`
	Category         string    `firestore:"category"`
	ContextWindow    int64     `firestore:"context_window"`
	DiscountPercent  float64   `firestore:"discount_percent"`
	Aliases          []string  `firestore:"aliases,omitempty"`
	ProviderModelID  string    `firestore:"provider_model_id"`
	CreatedAt        time.Time `firestore:"created_at,omitempty"`
	UpdatedAt        time.Time `firestore:"updated_at,omitempty"`
	Notes            string    `firestore:"notes"`
	Region           string    `firestore:"region"`
}

func (d *ModelCatalogEntryFirestore) ToProto() *catalogpb.ModelCatalogEntry { ... }
func (d *ModelCatalogEntryFirestore) FromProto(pb *catalogpb.ModelCatalogEntry) { ... }

Proto Options

Annotate your .proto files with proto2type options to control generation per-field or per-message:

import "proto2type/options.proto";

message User {
  string id = 1 [(proto2type.field).document_id = true];
  string email = 2;
  google.protobuf.Timestamp created_at = 3 [(proto2type.field).server_timestamp = true];
  string internal_notes = 4 [(proto2type.field).skip = true];
  Address address = 5 [(proto2type.field).inline = true];
  string display_name = 6 [(proto2type.field).name = "name"];
}
OptionTypeDescription
(proto2type.field).document_idboolMark as document ID — Firestore excludes it (ID is doc path), Mongo maps to _id
(proto2type.field).server_timestampboolServer-managed timestamp — Firestore uses serverTimestamp sentinel
(proto2type.field).skipboolExclude field from all generated types
(proto2type.field).omitemptyOptionalBoolForce omitempty on (TRUE) or off (FALSE)
(proto2type.field).inlineboolFlatten nested message into parent — Mongo: bson:",inline"
(proto2type.field).namestringOverride the storage field name
(proto2type.message).skipboolSkip generating types for entire message

Type Mapping

Proto TypeGo Domain Type
stringstring
int32, sint32, sfixed32int32
int64, sint64, sfixed64int64
uint32, fixed32uint32
uint64, fixed64uint64
floatfloat32
doublefloat64
boolbool
bytes[]byte
repeated T[]T
map<K, V>map[K]V
optional TT (with omitempty)
google.protobuf.Timestamptime.Time
google.protobuf.Durationtime.Duration
Nested message*MessageType
Enumint32 (default) or string (enum_as_string=true)

Rust Type Mapping

Proto TypeRust Domain TypeSQLite Row Type
stringStringString
int32, sint32, sfixed32i32i32
int64, sint64, sfixed64i64i64
uint32, fixed32u32u32
uint64, fixed64u64u64
floatf32f32
doublef64f64
boolboolbool
bytesVec<u8>Vec<u8>
repeated TVec<T>String (JSON)
map<K, V>HashMap<K, V>String (JSON)
optional TOption<T>Option<T>
google.protobuf.TimestampDateTime<Utc>i64 (epoch ms)
google.protobuf.Durationchrono::Durationi64 (milliseconds)
Nested messageOption<Box<T>>String (JSON)
Enumi32 (default) or String (enum_as_string=true)i32 / String

Kotlin Type Mapping

Proto TypeKotlin Domain Type
stringString
int32, sint32, sfixed32Int
int64, sint64, sfixed64Long
uint32, fixed32UInt
uint64, fixed64ULong
floatFloat
doubleDouble
boolBoolean
bytesByteArray
repeated TList<T> (default emptyList())
map<K, V>Map<K, V> (default emptyMap())
optional TT? (default null)
google.protobuf.Timestampkotlinx.datetime.Instant
google.protobuf.Durationkotlin.time.Duration
Nested messageT? (nullable)
Enum@Serializable enum class with @SerialName and fromValue(Int) companion
Oneof@Serializable sealed class with data class variants

Roadmap

PhaseScopeStatus
1Go + Firestore + MongoDB✅ Done
1.5Rust + SQLite✅ Done
2Python (absorbs proto2pydantic)✅ Done
3Kotlin + Validation✅ Done
4DynamoDB + Datastore + SpannerPlanned
5TypeScript + SQL ORMsPlanned

Development

This project uses Nix for reproducible development environments.

# Enter the dev shell (provides go, buf, protoc, pre-commit)
nix develop

# Run tests
nix develop -c go test ./...

# Regenerate golden files
nix develop -c go test ./... -update

# Build the plugin
nix develop -c go build -o protoc-gen-proto2type .

# Generate from test protos (Go)
cd testdata/proto && nix develop -c buf generate

# Generate from test protos (Rust)
cd testdata/proto && nix develop -c buf generate --template buf.gen.rust.yaml

Contributing

See CONTRIBUTING.md for development setup, PR process, and commit signing requirements.

License

Apache-2.0