protobuf-es TypeScript transport — target output + wire-correctness proof

Spike for Task 1 of the protobuf-es TS runtime mode. This is the golden reference the generator must reproduce in later tasks. Hand-authored, typechecked under nodenext, and proven wire-correct against protojson before any generator code was touched.

Environment (resolved)#

  • @bufbuild/protobuf: 2.12.1 (major v2 — the create / fromJson / toJson / MessageInitShape / GenMessage API is v2; v1 does not have this surface).
  • @bufbuild/protoc-gen-es: 2.12.1.
  • typescript: ^5.
  • protoc: libprotoc 32.1 (on PATH).
  • Node: v25.9.0.

Fixture proto used#

Authored a tiny self-contained 2-message service (.scratch/es-spike/get_albums.proto). The recommended multi_word_oneof.proto was not used because it lacks a repeated field, which the zero-value-materialization proof requires. sebuf HTTP annotations were also dropped from the spike proto: they do not affect the message codec or the _pb.ts message import surface (the actual contract), the hand-authored client hardcodes the path anyway, and keeping the import "sebuf/http/annotations.proto" forces protoc-gen-es to emit an import { file_sebuf_http_annotations } from "./sebuf/http/annotations_pb" dependency (plus a google/protobuf/descriptor chain) that is pure noise for this proof.

syntax = "proto3";
package spike.library.v1;
 
message Album {
  string id = 1;
  string title = 2;
}
message GetAlbumsRequest {
  string query = 1;
  int64 limit = 2;              // -> bigint in TS, "5" (string) in protojson
  repeated string genres = 3;
}
message GetAlbumsResponse {
  repeated Album albums = 1;    // zero-value materialization target
  int32 total = 2;
}
service AlbumService {
  rpc GetAlbums(GetAlbumsRequest) returns (GetAlbumsResponse) {}
}

Generated with:

protoc --plugin=protoc-gen-es=./node_modules/.bin/protoc-gen-es \
  --es_out=. --es_opt=target=ts --proto_path=. get_albums.proto

_pb.ts import surface protoc-gen-es produced (the contract for the generator)#

Header banner: // @generated by protoc-gen-es v2.12.1 with parameter "target=ts".

Imports emitted by protoc-gen-es (target=ts):

import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
import type { Message } from "@bufbuild/protobuf";

Exported symbols per message <Msg>:

  • export type <Msg> = Message<"<pkg>.<Msg>"> & { ...fields } — the message interface. Field type mapping observed: string -> string, int64 -> bigint, int32 -> number, repeated string -> string[], repeated <Msg> -> <Msg>[].
  • export const <Msg>Schema: GenMessage<<Msg>> = messageDesc(file_<proto>, <index>) — the runtime schema handle passed to create / fromJson / toJson.

Plus one export const file_<proto>: GenFile = fileDesc("<base64>") per file and one export const <Service>: GenService<{...}> per service (the service export is unused by the sebuf transport client — the client is hand-authored — but protoc-gen-es always emits it).

So for this fixture the client imports: GetAlbumsRequestSchema, GetAlbumsResponseSchema (values), and GetAlbumsResponse (type) from ./get_albums_pb.js.

Target sebuf transport client shape (VALIDATED — the exact contract)#

Layout is modules-mode: message schemas in <proto>_pb.js, shared errors.js a sibling module (spike used a flat ./errors.js; the real generator emits the relative depth to the root errors.js, e.g. ../../../errors.js). All relative imports carry .js extensions (Node ESM / nodenext).

// Code generated by protoc-gen-ts-client. DO NOT EDIT.
// source: get_albums.proto
 
import { create, fromJson, toJson, type MessageInitShape } from "@bufbuild/protobuf";
import { ApiError, ValidationError } from "./errors.js";
import { GetAlbumsRequestSchema, GetAlbumsResponseSchema } from "./get_albums_pb.js";
import type { GetAlbumsResponse } from "./get_albums_pb.js";
 
export interface AlbumServiceClientOptions {
  fetch?: typeof fetch;
  defaultHeaders?: Record<string, string>;
}
 
export interface AlbumServiceCallOptions {
  headers?: Record<string, string>;
  signal?: AbortSignal;
}
 
export class AlbumServiceClient {
  private baseURL: string;
  private fetchFn: typeof fetch;
  private defaultHeaders: Record<string, string>;
 
  constructor(baseURL: string, options?: AlbumServiceClientOptions) {
    this.baseURL = baseURL.replace(/\/+$/, "");
    this.fetchFn = options?.fetch ?? globalThis.fetch;
    this.defaultHeaders = { ...options?.defaultHeaders };
  }
 
  async getAlbums(req: MessageInitShape<typeof GetAlbumsRequestSchema>, options?: AlbumServiceCallOptions): Promise<GetAlbumsResponse> {
    const url = this.baseURL + "/api/album/v1/get-albums";
 
    const headers: Record<string, string> = {
      "Content-Type": "application/json",
      ...this.defaultHeaders,
      ...options?.headers,
    };
 
    const resp = await this.fetchFn(url, {
      method: "POST",
      headers,
      body: JSON.stringify(toJson(GetAlbumsRequestSchema, create(GetAlbumsRequestSchema, req))),
      signal: options?.signal,
    });
 
    if (!resp.ok) {
      return this.handleError(resp);
    }
 
    return fromJson(GetAlbumsResponseSchema, await resp.json(), { ignoreUnknownFields: true });
  }
 
  private async handleError(resp: Response): Promise<never> {
    const body = await resp.text();
    if (resp.status === 400) {
      try {
        const parsed = JSON.parse(body);
        if (parsed.violations) {
          throw new ValidationError(parsed.violations);
        }
      } catch (e) {
        if (e instanceof ValidationError) throw e;
      }
    }
    throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body);
  }
}

Design decisions LOCKED here (must be honored by the generator)#

  • Request param is MessageInitShape<typeof <Req>Schema>. Consumers pass plain object literals; the client calls create(<Req>Schema, req) then toJson(<Req>Schema, ...). int64 fields accept bigint / number / string in the init shape and serialize to a protojson string.
  • Response decode is fromJson(<Res>Schema, await resp.json(), { ignoreUnknownFields: true }). The ignoreUnknownFields: true option is MANDATORY (forward-compat with server-added fields).
  • errors.ts stays exactly as today (shared ApiError / ValidationError, copied verbatim from internal/tsclientgen/testdata/golden/errors.ts), imported with a .js extension.
  • All relative imports use .js extensions (nodenext).
  • Headers / method / path / AbortSignal / baseURL.replace(/\/+$/, "") / defaultHeaders spread order are identical to the current hand-rolled client (internal/tsclientgen/testdata/golden/multi_word_oneof_client.ts). The only body/response changes vs. the current client are the codec calls (toJson(create(...)) on send, fromJson(..., {ignoreUnknownFields:true}) on receive) replacing the raw JSON.stringify(req) / await resp.json() as T.

Proof 1 — typechecks under nodenext (tsc --noEmit)#

tsconfig.json: module + moduleResolution = nodenext, strict: true, noEmit: true, skipLibCheck: true, @bufbuild/protobuf resolvable in node_modules.

$ ./node_modules/.bin/tsc --noEmit -p tsconfig.json
=== tsc noEmit EXIT 0 ===

PASS — zero diagnostics.

Proof 2 — wire-correct: canonical JSON with omitted zero-values decodes to a complete object#

Compiled the spike to ESM JS in dist/ (raw node on .ts under nodenext is unreliable; compile then run), then:

$ node --input-type=module -e '
import { fromJson } from "@bufbuild/protobuf";
import { GetAlbumsResponseSchema } from "./dist/get_albums_pb.js";
const m = fromJson(GetAlbumsResponseSchema, {}, { ignoreUnknownFields: true });
console.log(Array.isArray(m.albums) ? "defaults filled: albums=[] (length " + m.albums.length + "), total=" + m.total : "FAIL");
'
defaults filled: albums=[] (length 0), total=0

PASS — fromJson({}, { ignoreUnknownFields: true }) materializes the omitted repeated albums as [] and omitted int32 total as 0. This is the whole point: the codec fills proto3 zero-values that protojson omits, so consumers get a complete object instead of undefined fields.

Bonus — request encode round-trip (int64 -> protojson string)#

$ node --input-type=module -e '
import { create, toJson } from "@bufbuild/protobuf";
import { GetAlbumsRequestSchema } from "./dist/get_albums_pb.js";
console.log(JSON.stringify(toJson(GetAlbumsRequestSchema, create(GetAlbumsRequestSchema, { query: "jazz", limit: 5n, genres: ["a","b"] }))));
'
{"query":"jazz","limit":"5","genres":["a","b"]}

Confirms create + toJson on a plain init object produces canonical protojson (int64 5n -> the string "5").

Notes for the generator tasks#

  • The scratch build under .scratch/es-spike/ is throwaway and git-ignored (.scratch/ added to .gitignore). Only this note is committed.
  • The generator must emit the _pb.ts via protoc-gen-es (target=ts) and, separately, the <proto>_client.ts matching the shape above. Message-type import specifier changes from ./<proto>.js (current hand-rolled types module) to ./<proto>_pb.js (protoc-gen-es output).
  • errors.ts is unchanged and remains the single shared root module.