protoc-gen-open-models

July 11, 2026 · View on GitHub

This is a protoc plugin that generates TypeScript code from proto files for the @furo/open-models module. It creates three representations per message — a Literal interface, a Transport interface, and a Model class — along with TypeScript enums and REST service classes.

Usage

# Build the plugin
go build -o protoc-gen-open-models .

# Check the version
./protoc-gen-open-models --version

# Run protoc with the plugin
protoc \
  -I./proto_dependencies \
  -I./proto \
  --open-models_out=./generated \
  your_proto_files.proto

Generated Types

For each proto message, one .ts file is generated containing three types.

Literal Interface (I<Name>)

A plain TypeScript interface using camelCase field names (JSON convention). All fields are optional.

Proto:

message Person {
  string first_name = 1;
  int32 age = 2;
  repeated string tags = 3;
}

Generated:

export interface IPerson {
  firstName?: string;
  age?: number;
  tags?: string[];
}

Transport Interface (T<Name>)

A TypeScript interface using snake_case field names (proto wire convention). All fields are optional.

export interface TPerson {
  first_name?: string;
  age?: number;
  tags?: string[];
}

Model Class (<Name>)

A runtime class extending FieldNode with getters, setters, field metadata, and registry support.

export class Person extends FieldNode {
  private _firstName: STRING;
  private _age: INT32;
  private _tags: ARRAY<STRING, string>;

  constructor(
    initData?: IPerson,
    parent?: FieldNode,
    parentAttributeName?: string,
  )

  public get firstName(): string { ... }
  public set firstName(v: string) { ... }

  public get age(): number { ... }
  public set age(v: number) { ... }

  public get tags(): ARRAY<STRING, string> { ... }
  public set tags(v: IPerson["tags"]) { ... }

  fromLiteral(data: IPerson): void
  toLiteral(): IPerson
}

Registry.register('person.Person', Person);

Each field carries metadata (__meta.nodeFields) including the JSON name, proto name, type constructor, optional constraints from OpenAPI annotations, and the field description from proto comments.

Enums

Proto enums become TypeScript string enums where each value maps to its own name.

Proto:

enum Colour {
  RED = 0;
  GREEN = 1;
  BLUE = 2;
}

Generated:

export enum Colour {
  RED = "RED",
  GREEN = "GREEN",
  BLUE = "BLUE",
}

In Model types, enum fields use ENUM<EnumType>.

Services

RPC methods annotated with google.api.http are generated as StrictFetcher properties.

Proto:

import "google/api/annotations.proto";

service PersonService {
  rpc GetPerson(GetPersonRequest) returns (Person) {
    option (google.api.http) = {
      get: "/api/persons/{person_id}"
    };
  }

  rpc CreatePerson(Person) returns (Person) {
    option (google.api.http) = {
      post: "/api/persons"
      body: "*"
    };
  }
}

Generated:

export class PersonService {
  public GetPerson: StrictFetcher<IGetPersonRequest, IPerson> =
    new StrictFetcher<IGetPersonRequest, IPerson>(
      API_OPTIONS,
      'GET',
      '/api/persons/{person_id}',
      GetPersonRequest,
      Person,
    );

  public CreatePerson: StrictFetcher<IPerson, IPerson> =
    new StrictFetcher<IPerson, IPerson>(
      API_OPTIONS,
      'POST',
      '/api/persons',
      Person,
      Person,
      '*',
    );
}

Supported HTTP verbs: GET, PUT, POST, PATCH, DELETE, and custom verbs.

Server-streaming RPCs wrap the response type in AsyncIterable<>.

Primitive Type Mappings

Proto TypeLiteral / TransportModel TypeModel Primitive
stringstringSTRINGstring
bytesstringBYTESstring
boolbooleanBOOLEANboolean
int32numberINT32number
int64stringINT64bigint
doublenumberDOUBLEnumber
floatnumberFLOATnumber
uint32numberUINT32number
uint64stringUINT64bigint
fixed32numberFIXED32number
fixed64stringFIXED64bigint
sfixed32numberSFIXED32number
sfixed64stringSFIXED64bigint
sint32numberSINT32number
sint64stringSINT64bigint

64-bit integer types map to string in Literal/Transport (JSON safe) and bigint in Model.

Well-Known Types

Proto TypeLiteral / TransportModel
google.protobuf.StringValuestringstring
google.protobuf.BytesValuestringstring
google.protobuf.BoolValuebooleanboolean
google.protobuf.Int32Valuenumbernumber
google.protobuf.Int64Valuestringbigint
google.protobuf.FloatValuenumbernumber
google.protobuf.DoubleValuenumbernumber
google.protobuf.UInt32Valuenumbernumber
google.protobuf.UInt64Valuestringbigint
google.protobuf.Timestampstringstring
google.protobuf.Durationstringstring
google.protobuf.StructJSONObjectJSONObject
google.protobuf.EmptyRecord<string, never>Record<string, never>
google.protobuf.FieldMaskstring[]string[]
google.protobuf.AnyIAnyIAny

Complex Type Handling

repeated (Arrays)

Repeated fields become arrays in Literal/Transport and ARRAY<> in Model.

Proto:

message Example {
  repeated string tags = 1;
  repeated Person people = 2;
}

Literal/Transport:

tags?: string[];
people?: IPerson[];

Model:

private _tags: ARRAY<STRING, string>;
private _people: ARRAY<Person, IPerson>;

map<K,V> (Maps)

Map fields become object types with index signatures in Literal/Transport and MAP<> in Model.

Proto:

message Example {
  map<string, int32> scores = 1;
  map<string, Person> people = 2;
}

Literal/Transport:

scores?: { [key: string]: number };
people?: { [key: string]: IPerson };

Model:

private _scores: MAP<string, INT32, number>;
private _people: MAP<string, Person, IPerson>;

oneof

Requires @furo/open-models 1.18.0 or newer.

Each field inside a oneof block gets a oneofGroup property in its metadata entry. The constructor also initializes a oneofGroups Map that the runtime uses to enforce mutual exclusivity.

Proto:

message Shape {
  string name = 1;
  oneof area {
    float radius = 2;
    float length = 3;
  }
}

Model (relevant parts):

this.__meta.nodeFields = [
  {
    fieldName: 'name',
    protoName: 'name',
    FieldConstructor: STRING,
    description: ''
  },
  {
    fieldName: 'radius',
    protoName: 'radius',
    FieldConstructor: FLOAT,
    description: '',
    oneofGroup: 'area'
  },
  {
    fieldName: 'length',
    protoName: 'length',
    FieldConstructor: FLOAT,
    description: '',
    oneofGroup: 'area'
  }
];

this.__meta.oneofGroups = new Map([
  ['area', undefined],
]);

The runtime will clear sibling fields in the same group when one is set, matching standard protobuf oneof semantics.

Self-Recursion and Deep Recursion

When a message references itself (directly or through a chain), the Model type uses RECURSION<> to prevent infinite instantiation.

Proto:

message TreeNode {
  string label = 1;
  TreeNode child = 2;
}

Model:

private _child: RECURSION<TreeNode, ITreeNode>;

The plugin performs cycle detection across the message graph to identify both direct and deep recursion.

OpenAPI v3 Annotations

Field-Level Constraints (openapi.v3.property)

Field annotations are extracted and stored as FieldConstraints in the Model's metadata.

import "openapi/v3/annotations.proto";

message Product {
  string name = 1 [(openapi.v3.property) = {
    min_length: 1
    max_length: 255
    pattern: "^[a-zA-Z]"
  }];

  int32 quantity = 2 [(openapi.v3.property) = {
    minimum: 0
    maximum: 1000
    default: {number: 1}
  }];

  float price = 3 [(openapi.v3.property) = {
    read_only: true
  }];
}

Supported constraint fields:

  • Numeric: minimum, maximum, exclusive_minimum, exclusive_maximum, multiple_of
  • String: pattern, min_length, max_length
  • Array: min_items, max_items, unique_items
  • Object: min_properties, max_properties
  • Flags: read_only, write_only, deprecated, nullable, required
  • Descriptive: title, description, format, type
  • Defaults: default (applied during Model construction when no init data is provided)

Message-Level Annotations (openapi.v3.schema)

Mark specific fields as required at the message level:

message Account {
  option (openapi.v3.schema) = {
    required: ["account_id", "display_name"]
  };
  string account_id = 1;
  string display_name = 2;
  string description = 3;
}

Reserved Word Handling

TypeScript class names that collide with built-in globals are automatically prefixed with X:

Proto NameGenerated Name
JSONObjectXJSONObject
ObjectXObject
AnyXAny
StringXString
NumberXNumber
DateXDate

This applies to the generated class, interface, and file names.

Debugging

Use protoc-gen-debugfile to capture the raw CodeGeneratorRequest, then replay it for IDE-friendly debugging:

# Capture the request
protoc \
  --debugfile_out=. \
  --debugfile_opt=/tmp/request.bin \
  --open-models_out=./generated \
  -I./proto_dependencies -I./proto \
  $(find proto -iname "*.proto")

# Replay without protoc
cd protoc-gen-open-models
go build -o protoc-gen-open-models .
./protoc-gen-open-models --replay-request=/tmp/request.bin