proto2type

July 2, 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
  • 🌐 Multi-language — Go and Rust supported, Python / Kotlin / 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

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

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

Roadmap

PhaseScopeStatus
1Go + Firestore + MongoDB✅ Done
1.5Rust + SQLite🚧 Current
2Python (absorbs proto2pydantic)Planned
3DynamoDB + Datastore + KotlinPlanned
4Spanner + TypeScript + 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