gcode is a code generator that takes proto files as input and produces Go structs, serialization methods, validation logic, HTTP handlers, and TypeScript type definitions. It is designed for backend services that use protobuf as a schema language but do not need the full gRPC stack.
What gcode generates:
| Input | Output |
|---|---|
Any .proto file |
Go struct + MarshalBinary / UnmarshalBinary + Validate() |
.meta.proto schema file |
Three derived proto files (entity / create / update) via gen-proto |
.entity.proto |
Go struct with GORM tags + TableName() + DeepClone() |
.create.proto |
Go struct with Validate() + ToEntity() + DeepClone() |
.update.proto |
Go struct with Validate() + ToMap() + ApplyTo() + DeepClone() |
| Service definition | Go interface + gin HTTP handler factory functions |
Any .proto file |
TypeScript interfaces + enums + validation metadata |
Scope and constraints:
- Targets proto3 only. proto2 is not supported.
- Generates Go code for GORM-based persistence and gin-based HTTP services. Other ORMs or HTTP frameworks are not supported.
- Does not generate gRPC stubs. The generated Go interface is a plain Go interface, not a gRPC service.
- Unsupported proto features (streaming RPC,
map<K,V>,oneof, well-known types) cause a generation-time error rather than silently producing incorrect code. See Known Limitations.
go install github.com/pinealctx/gcode/cmd/gcode@latestVerify:
gcode -hCLI flags:
gcode [flags] Generate Go code from proto files
-in string Input proto directory
-out string Output directory
gcode version Print version information
gcode gen-proto [flags] Generate entity/create/update proto files from schema (.meta.proto) files
-in string Input proto directory (generated files are written to the same directory)
gcode gen-ts [flags] Generate TypeScript type definitions from proto files
-in string Input proto directory
-out string Output directory
Generated code depends on the following public packages. Install them in your project:
# Serialization runtime (required by *.pb.dao.go)
go get github.com/pinealctx/gcode/runtime
# Validation runtime (required by *.pb.dao.validate.go)
go get github.com/pinealctx/gcode/validateruntime
# HTTP adapter runtime (required by *.pb.http.go)
go get github.com/pinealctx/gcode/httpruntimeIf you only generate structs and serialization code (no validate or HTTP), only runtime is needed.
Runtime import path is fixed: Generated code always imports
github.com/pinealctx/gcode/runtime,github.com/pinealctx/gcode/validateruntime, andgithub.com/pinealctx/gcode/httpruntime. These paths are hardcoded in the generator and cannot be customized. If you fork or rename the module, you will need to update the generated import paths accordingly — this is a major-version-level change.
1. Write a proto file
// proto/user.proto
syntax = "proto3";
package myapp;
option go_package = "myapp/dao;dao";
message User {
string name = 1;
int32 age = 2;
}2. Generate code
gcode -in proto/ -out dao/3. Use the generated struct
import "myapp/dao"
u := &dao.User{Name: "Alice", Age: 30}
// Serialize to protobuf wire format
wire, err := u.MarshalBinary()
// Deserialize
var u2 dao.User
err = u2.UnmarshalBinary(wire)Note: The generated code imports
github.com/pinealctx/gcode/runtime. Runninggo buildbeforego getwill produce a missing module error — that is expected. Rungo getfirst, then build.
The following example walks through the complete flow from proto definition to a working HTTP service, based on real code in testdata/compat/.
Note: The proto examples below are simplified to highlight annotation usage. Full proto files are in
testdata/compat/proto/.
Note:
gcode/options.protoandbuf/validate/validate.protoare embedded in the gcode binary. No extra installation or download needed — just import them directly in your proto files.
// proto/person.meta.proto
syntax = "proto3";
package myapp;
option go_package = "myapp/dao;dao";
import "buf/validate/validate.proto";
import "gcode/options.proto";
// Mark this file as a schema source. gen-proto reads this file and generates
// person.entity.proto, person.create.proto, and person.update.proto.
option (gcode.schema) = {};
// Source message: do NOT use optional on fields here.
// Optionality is determined by the derived message annotations, not the source.
// All fields are plain (non-optional) — gen-proto controls pointer semantics in derived protos.
message Person {
string name = 1 [
(buf.validate.field).string.min_len = 1,
(buf.validate.field).string.max_len = 100
];
int32 age = 2 [
(buf.validate.field).int32.gte = 0,
(buf.validate.field).int32.lte = 150
];
string email = 3 [(buf.validate.field).string.email = true];
string nickname = 4 [
(buf.validate.field).string.min_len = 1,
(buf.validate.field).string.max_len = 10
];
// Generate update derived message: PersonUpdateByName
// condition_fields are WHERE clause fields, non-pointer in derived struct, excluded from ToMap()
option (gcode.update_message) = {
name: "PersonUpdateByName"
condition_fields: ["name"]
ignore_fields: []
};
// Generate create derived message: PersonCreate
// All fields default to pointer (optional) in the derived struct.
// required_fields forces specific fields to non-pointer (required).
option (gcode.create_message) = {
name: "PersonCreate"
ignore_fields: []
required_fields: ["nickname"]
};
}// proto/person_service.proto
syntax = "proto3";
package myapp;
option go_package = "myapp/dao;dao";
import "person.entity.proto";
import "person.create.proto";
import "person.update.proto";
message CreatePersonResponse { string id = 1; }
message GetPersonRequest { string id = 1; }
message GetPersonResponse { string name = 1; int32 age = 2; }
message UpdatePersonResponse { bool ok = 1; }
message DeletePersonRequest { string id = 1; }
message DeletePersonResponse { bool ok = 1; }
// PersonService provides CRUD operations for person records.
service PersonService {
rpc CreatePerson(PersonCreate) returns (CreatePersonResponse);
rpc GetPerson(GetPersonRequest) returns (GetPersonResponse);
rpc UpdatePerson(PersonUpdateByName) returns (UpdatePersonResponse);
rpc DeletePerson(DeletePersonRequest) returns (DeletePersonResponse);
}Note:
person.entity.proto,person.create.proto, andperson.update.protoare generated by thegen-protocommand in Step 2. Do not write them manually.
gen-proto reads .meta.proto schema files and generates three types of proto files for each schema:
gcode gen-proto -in proto/Schema file naming rule:
gen-protoidentifies schema files exclusively by the.meta.protosuffix. This is the only naming convention it enforces. Files without this suffix — includingcommon.proto, service protos, and any other shared definition files — are not processed bygen-protodirectly. They are resolved automatically as dependencies when protocompile parses the.meta.protofiles.If a
.meta.protofile importscommon.proto, the generated*.create.protoand*.update.protowill automatically includeimport "common.proto"— no manual import management needed.
Result:
proto/
person.meta.proto ← schema source (unchanged)
common.proto ← shared definitions (unchanged, not processed by gen-proto)
person_service.proto ← service definition (unchanged, not processed by gen-proto)
person.entity.proto ← generated: Person message (no validate, with gorm)
person.update.proto ← generated: PersonUpdateByName message (with validate)
person.create.proto ← generated: PersonCreate message (with validate)
person.entity.proto— contains thePersonstruct definition with gorm annotations. Nobuf.validateannotations;Person.Validate()returns nil.person.create.proto— containsPersonCreatewith validate annotations copied from the schema.PersonCreate.Validate()enforces all rules.person.update.proto— containsPersonUpdateByNamewith validate annotations copied from the schema.PersonUpdateByName.Validate()enforces all rules.
Note:
gcode gen-protooverwrites existing generated files on every run. Do not manually edit*.entity.proto,*.create.proto, or*.update.proto— changes will be lost on the next run.
gcode -in proto/ -out dao/Result:
dao/
person.entity.pb.dao.go ← Person struct + serialization methods
person.entity.pb.dao.validate.go ← Person.Validate() — returns nil (no validate annotations)
person.update.pb.dao.go ← PersonUpdateByName struct + ToMap() + ApplyTo()
person.update.pb.dao.validate.go ← PersonUpdateByName.Validate()
person.create.pb.dao.go ← PersonCreate struct + ToEntity()
person.create.pb.dao.validate.go ← PersonCreate.Validate()
person_service.pb.dao.go ← request/response message structs
person_service.pb.dao.validate.go
person_service.pb.rpc.go ← PersonService interface
person_service.pb.http.go ← gin handler factory functions
Field pointer rules in derived structs:
| Struct | Field kind | Go type | Notes |
|---|---|---|---|
PersonCreate |
in required_fields |
T (non-pointer) |
caller must provide a value |
PersonCreate |
all other fields | *T (pointer) |
nil = not provided, skips validation |
PersonUpdateByName |
in condition_fields |
T (non-pointer) |
WHERE clause, excluded from ToMap() |
PersonUpdateByName |
all other fields | *T (pointer) |
nil = not updating this field |
p := &dao.Person{Name: "Alice", Age: 30, Email: "alice@example.com"}
// Serialize to protobuf wire format
wire, err := p.MarshalBinary()
if err != nil {
log.Fatal(err)
}
// Deserialize (strict mode: duplicate fields return error)
var p2 dao.Person
if err := p2.UnmarshalBinary(wire); err != nil {
log.Fatal(err)
}
// Deserialize (lenient mode: duplicate fields use last value)
var p3 dao.Person
if err := p3.UnmarshalBinaryLenient(wire); err != nil {
log.Fatal(err)
}JSON tag naming: Proto field names use
snake_case(e.g.created_at), but generated JSON tags usecamelCase(e.g.json:"createdAt"), consistent with protoc-gen-go behavior.
optional fields are generated as pointer types; nil means "not set":
nickname := "ali"
p := &dao.Person{
Name: "Alice",
Nickname: &nickname, // optional string → *string
}
if p.Nickname != nil {
fmt.Println(*p.Nickname)
}Proto enum definitions generate Go int32 type aliases and constants:
enum Status {
STATUS_UNSPECIFIED = 0;
STATUS_ACTIVE = 1;
STATUS_INACTIVE = 2;
}
message User {
Status status = 1;
}Generated Go code:
type Status int32
const (
Status_STATUS_UNSPECIFIED Status = 0
Status_STATUS_ACTIVE Status = 1
Status_STATUS_INACTIVE Status = 2
)
type User struct {
Status Status `json:"status"`
}Use (buf.validate.field).enum.defined_only = true to reject undefined values — see Annotations Reference.
Proto allows messages nested inside other messages. gcode flattens them to top-level Go types:
message Order {
message Item {
string product = 1;
int32 quantity = 2;
}
Item item = 1;
}Generated Go code (nested type name becomes OrderItem):
type OrderItem struct {
Product string `json:"product"`
Quantity int32 `json:"quantity"`
}
type Order struct {
Item *OrderItem `json:"item"`
}Naming rule:
Parent_Childin proto →ParentChildin Go (via GoCamelCase). The generated Go code has no nesting — all types are top-level.
Validate() is generated for every message. For entity messages (from *.entity.proto), Validate() returns nil — they carry no validate annotations. Validation is meaningful on create/update messages:
import (
"errors"
"fmt"
"github.com/pinealctx/gcode/validateruntime"
"myapp/dao"
)
// PersonCreate.Validate() enforces all rules from the schema
req := &dao.PersonCreate{Name: "", Age: 200}
if err := req.Validate(); err != nil {
var ve *validateruntime.ValidationError
if errors.As(err, &ve) {
fmt.Printf("field=%s rule=%s msg=%s\n", ve.Field, ve.Rule, ve.Message)
// field=name rule=min_len msg=length must be >= 1
}
}
// PersonUpdateByName.Validate() also enforces all rules
upd := &dao.PersonUpdateByName{Name: "Alice"}
if err := upd.Validate(); err != nil { ... }Validate() is a public method on every generated struct. You can call it anywhere — in service implementations, message queue consumers, batch imports, etc. The generated HTTP handler also calls req.Validate() automatically (after binding, before calling the service). The two don't conflict: the handler's built-in call ensures validation at the transport layer; the public method lets you reuse the same validation logic in any other context.
PersonUpdateByName.ToMap() returns only non-nil fields, suitable for GORM's Updates method for partial updates:
age := int32(31)
req := &dao.PersonUpdateByName{
Name: "Alice", // condition_fields — excluded from ToMap()
Age: &age, // only update age
}
// ToMap() returns map[string]any{"age": 31}
// Name is a condition_field and is not included in the map
db.Model(&dao.Person{}).Where("name = ?", req.Name).Updates(req.ToMap())ToMap() key semantics:
ToMap()uses the gorm column name as the map key. If a field has a(gcode.field).gorm.columnoverride, the key uses the overridden column name; otherwise the proto field name is used. This ensuresdb.Updates(map)matches the correct database column (GORM uses map keys directly as column names — it does not walk struct tags).Validate rules: Validate rules are defined in the schema (
.meta.proto) and copied bygen-protointo the generated*.create.proto/*.update.protofiles. The render layer reads them directly from those proto fields — no cross-file lookup. Optional fields (pointer types) skip validation when nil; condition_fields are validated without a zero-value guard. See Annotations Reference — Validate Inheritance.
Every generated struct has a DeepClone() method that returns a fully independent copy with no shared memory. This is useful when you need to preserve the original state before applying a mutation:
// Preserve the original before applying an update
original := entity.DeepClone()
updateMsg.ApplyTo(entity)
// Compare original vs entity for diff, audit log, or optimistic-lock conflict detection
if original.Age != entity.Age {
log.Printf("age changed: %d → %d", original.Age, entity.Age)
}DeepClone() handles all field kinds correctly:
- Scalar and enum fields: copied by value
- Optional fields (
*T): a new pointer is allocated — mutating the clone's field does not affect the original - Bytes and repeated fields: new slices are allocated and contents are copied
- Nested message fields: recursively cloned
- Nil receiver: returns nil
Generated PersonService interface:
// dao/person_service.pb.rpc.go (generated, do not edit)
type PersonService interface {
CreatePerson(ctx context.Context, req *PersonCreate) (*CreatePersonResponse, error)
GetPerson(ctx context.Context, req *GetPersonRequest) (*GetPersonResponse, error)
UpdatePerson(ctx context.Context, req *PersonUpdateByName) (*UpdatePersonResponse, error)
DeletePerson(ctx context.Context, req *DeletePersonRequest) (*DeletePersonResponse, error)
}Implement the interface:
type personServiceImpl struct {
db *gorm.DB
}
func (s *personServiceImpl) CreatePerson(ctx context.Context, req *dao.PersonCreate) (*dao.CreatePersonResponse, error) {
// Validate() is called automatically by the HTTP handler.
// In non-HTTP contexts (direct calls, message queues), call it manually:
// if err := req.Validate(); err != nil { return nil, err }
return &dao.CreatePersonResponse{Id: "new-id"}, nil
}
// Implement remaining methods...gin dependency: The generated
*.pb.http.gofiles import gin. Add it to your project:go get github.com/gin-gonic/gin
Generated handler factory functions accept a service interface and an optional list of interceptors, returning gin.HandlerFunc:
// dao/person_service.pb.http.go (generated, do not edit)
func CreatePersonHandler(svc PersonService, interceptors ...handlerx.Interceptor[*PersonCreate, *CreatePersonResponse]) gin.HandlerFunc
func GetPersonHandler(svc PersonService, interceptors ...handlerx.Interceptor[*GetPersonRequest, *GetPersonResponse]) gin.HandlerFunc
func UpdatePersonHandler(svc PersonService, interceptors ...handlerx.Interceptor[*PersonUpdateByName, *UpdatePersonResponse]) gin.HandlerFunc
func DeletePersonHandler(svc PersonService, interceptors ...handlerx.Interceptor[*DeletePersonRequest, *DeletePersonResponse]) gin.HandlerFuncThe interceptors parameter is variadic — existing calls like dao.CreatePersonHandler(svc) continue to work without any changes.
Register routes:
func main() {
svc := &personServiceImpl{db: setupDB()}
r := gin.New()
// ⚠️ DefaultErrorHandler (or a custom equivalent) MUST be registered.
// Generated handlers use c.Error(err)+return to propagate errors and do
// not write their own error responses. Without this middleware, all error
// paths silently return HTTP 200 with an empty body.
r.Use(httpruntime.DefaultErrorHandler())
// Route paths and HTTP methods are fully controlled by you
r.POST("/persons", dao.CreatePersonHandler(svc))
r.GET("/persons/:id", dao.GetPersonHandler(svc))
r.PUT("/persons/:name", dao.UpdatePersonHandler(svc))
r.DELETE("/persons/:id", dao.DeletePersonHandler(svc))
r.Run(":8080")
}Response format (provided by httpruntime):
// Success
{"code": 0, "data": {"id": "new-id"}}
// Validation error (CodeValidationErr)
{"code": 1001, "error": {"msg": "length must be >= 1"}}
// Business error (CodeDefaultErr, or CodedError.Code())
{"code": 5000, "error": {"msg": "internal error"}}Implement httpruntime.CodedError to return a custom error code:
type AppError struct {
code int
msg string
}
func (e *AppError) Error() string { return e.msg }
func (e *AppError) Code() int { return e.code }
// This error produces code 404 instead of the default CodeDefaultErr (5000)
return nil, &AppError{code: 404, msg: "person not found"}# Create a person
curl -X POST http://localhost:8080/persons \
-H "Content-Type: application/json" \
-d '{"nickname": "alice", "email": "alice@example.com"}'
# → {"code": 0, "data": {"id": "new-id"}}
# Validation error (nickname too long)
curl -X POST http://localhost:8080/persons \
-H "Content-Type: application/json" \
-d '{"nickname": "this-name-is-way-too-long"}'
# → {"code": 1001, "error": {"msg": "length must be <= 10"}}
# Get a person
curl http://localhost:8080/persons/some-id
# → {"code": 0, "data": {"name": "Alice", "age": 30}}Each handler delegates to httpruntime.NewHandler, which calls c.ShouldBindJSON internally. gin does not impose a default body size limit. For production deployments, set a limit via middleware to prevent oversized payloads:
import "net/http"
func MaxBodyBytes(n int64) gin.HandlerFunc {
return func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, n)
c.Next()
}
}
r.Use(MaxBodyBytes(1 << 20)) // 1 MiBGenerated handlers pass c.Request.Context() to the service layer. gin does not inject a deadline by default. To add a timeout, inject it via middleware:
func TimeoutMiddleware(d time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), d)
defer cancel()
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
r.Use(TimeoutMiddleware(5 * time.Second))Every generated handler has panic recovery built in — a panic in the service method is caught and converted to an error, so the server never crashes. On top of that, you can inject custom interceptors per route for logging, metrics, tracing, or any cross-cutting concern.
An interceptor has the signature:
func(ctx context.Context, req *Req, next handlerx.Handler[*Req, *Resp]) (*Resp, error)Example: request logging interceptor
import (
"context"
"log/slog"
"github.com/pinealctx/x/handlerx"
)
func LoggingInterceptor[Req, Resp any](logger *slog.Logger) handlerx.Interceptor[*Req, *Resp] {
return func(ctx context.Context, req *Req, next handlerx.Handler[*Req, *Resp]) (*Resp, error) {
logger.Info("request", "type", fmt.Sprintf("%T", req))
resp, err := next(ctx, req)
if err != nil {
logger.Error("request failed", "error", err)
}
return resp, err
}
}Registering routes with interceptors
logger := slog.Default()
// No interceptors — works exactly as before
r.POST("/persons", dao.CreatePersonHandler(svc))
// With a logging interceptor on a specific route
r.DELETE("/persons/:id", dao.DeletePersonHandler(svc,
LoggingInterceptor[DeletePersonRequest, DeletePersonResponse](logger),
))Interceptors are applied in the order they are passed, inside the built-in panic recovery layer. The service method is always the innermost call.
For detailed documentation and examples, see Annotations Reference.
| Annotation | Type | Description |
|---|---|---|
(gcode.message).gorm.table |
string | Override gorm table name |
(gcode.update_message).name |
string | Name of the generated update derived message |
(gcode.update_message).condition_fields |
[]string | WHERE clause fields, excluded from ToMap() |
(gcode.update_message).ignore_fields |
[]string | Fields excluded from the derived message |
(gcode.create_message).name |
string | Name of the generated create derived message |
(gcode.create_message).ignore_fields |
[]string | Fields excluded from the derived message |
(gcode.create_message).required_fields |
[]string | Fields that become non-pointer types (required) in the derived message |
| Annotation | Type | Description |
|---|---|---|
(gcode.field).json.omitempty |
bool | Generate json:"field,omitempty" |
(gcode.field).json.ignore |
bool | Generate json:"-" |
(gcode.field).gorm.column |
string | Override gorm column name |
(gcode.field).validate_message |
string | Override the default error message for all constraints on this field |
| Annotation | Applies to | Description |
|---|---|---|
(buf.validate.field).string.min_len |
string | Minimum byte length |
(buf.validate.field).string.max_len |
string | Maximum byte length |
(buf.validate.field).string.email |
string | Email format validation |
(buf.validate.field).string.uri |
string | URI format validation |
(buf.validate.field).string.pattern |
string | RE2 regex match |
(buf.validate.field).string.in |
string | Allowed values (can be declared multiple times) |
(buf.validate.field).string.not_in |
string | Disallowed values (can be declared multiple times) |
(buf.validate.field).int32.gte |
int32 | Greater than or equal |
(buf.validate.field).int32.lte |
int32 | Less than or equal |
(buf.validate.field).int32.gt |
int32 | Greater than |
(buf.validate.field).int32.lt |
int32 | Less than |
(buf.validate.field).int32.in |
int32 | Allowed values |
(buf.validate.field).int32.not_in |
int32 | Disallowed values |
(buf.validate.field).int64.* |
int64 | Same as int32 series |
(buf.validate.field).float.* |
float32/64 | Same as int32 series (gte/lte/gt/lt) |
(buf.validate.field).bytes.min_len |
bytes | Minimum byte count |
(buf.validate.field).bytes.max_len |
bytes | Maximum byte count |
(buf.validate.field).repeated.min_items |
repeated | Minimum element count |
(buf.validate.field).repeated.max_items |
repeated | Maximum element count |
(buf.validate.field).repeated.items.* |
repeated | Apply constraints to each element |
(buf.validate.field).enum.defined_only |
enum | Only allow defined enum values |
(buf.validate.field).enum.not_in |
enum | Disallowed enum numbers |
(buf.validate.field).required |
message/bytes | Disallow nil / empty |
(buf.validate.field).message.required |
message | Nested message must not be nil |
gcode generates TypeScript type definitions from proto files, enabling type-safe frontend code with consistent validation metadata.
No additional dependencies. The gen-ts command uses the same proto parsing pipeline as Go generation.
If your proto files use gcode.update_message / gcode.create_message annotations, run gen-proto first (see Step 2 in the Go section above) to generate the derived proto files. Then:
gcode gen-ts -in proto/ -out ts/Result:
ts/
person.entity.pb.ts ← Person interface + Status enum (no validation metadata)
person.create.pb.ts ← PersonCreate interface + PersonCreateRules validation metadata
person.update.pb.ts ← PersonUpdateByName interface + PersonUpdateByNameRules validation metadata
person_service.pb.ts ← request/response interfaces + validation metadata
Interfaces — proto messages become TypeScript interfaces with camelCase property names:
export interface Person {
name: string
age: number
status: Status
scores: number[]
nickname?: string // optional field → T | undefined
}Enums — proto enums become TypeScript enums with a name mapping record:
export enum Status {
STATUS_UNSPECIFIED = 0,
STATUS_ACTIVE = 1,
STATUS_INACTIVE = 2,
}
export const StatusName: Record<Status, string> = {
[Status.STATUS_UNSPECIFIED]: "STATUS_UNSPECIFIED",
[Status.STATUS_ACTIVE]: "STATUS_ACTIVE",
[Status.STATUS_INACTIVE]: "STATUS_INACTIVE",
} as constValidation metadata — buf/validate annotations become typed constant objects:
export const PersonRules = {
name: { required: false, type: "string", minLength: 1, maxLength: 100 },
age: { required: false, type: "integer", minimum: 0, maximum: 150 },
status: { required: false, type: "enum", notIn: [0], definedOnly: true },
email: { required: false, type: "string", format: "email" },
} as constCross-file imports — types defined in another .pb.ts file are automatically imported:
import { Status } from "./person.pb.js"| Proto type | TypeScript type | Notes |
|---|---|---|
| int32, uint32, float, double | number |
|
| int64, uint64 | string |
Avoids JS precision loss |
| bool | boolean |
|
| string | string |
|
| bytes | string |
base64 encoded |
| enum | enum + Record |
Numeric enum + name mapping |
| repeated T | T[] |
|
| optional T | T | undefined |
Shorthand: field?: T |
| message | interface |
The compatibility test suite in testdata/compat/ts-test/ provides automated verification:
cd testdata/compat/ts-test
# Install dependencies (first time only)
npm install
# Type check — tsc --noEmit on all generated files
npm run typecheck
# Runtime tests — verify enum values, name mapping, validation rules, cross-file imports
npm testThese tests are also integrated into Go via go test ./testdata/compat/... (TestTSTypeCheck, TestTSRuntime), which automatically invokes npm when Node.js is available.
The following proto features are not supported. When unsupported features are encountered, gcode reports an error and exits rather than silently producing incorrect code.
| Limitation | Severity | Details |
|---|---|---|
| Streaming RPC not supported | Medium | Service definitions with stream keyword cause an error exit |
map<K,V> not supported |
Medium | Map fields cause an error at parse time |
oneof not supported |
Medium | Non-synthetic oneof fields cause an error at parse time |
| Well-known types not supported | Medium | google.protobuf.* types (e.g. Timestamp) cause an error |
| proto2 not supported | Low | Only syntax = "proto3" is accepted |
| HTTP path params not supported | Low | Generated handlers bind from request body only. Extract path params (e.g. /users/:id) manually in the service layer. |
| Flat Go output directory | Low | Go generation writes all generated Go files into one output package directory. Proto files with the same basename are rejected in one generation run, even when they are in different source subdirectories. |