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 — thecreate/fromJson/toJson/MessageInitShape/GenMessageAPI 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 tocreate/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 callscreate(<Req>Schema, req)thentoJson(<Req>Schema, ...). int64 fields acceptbigint/number/ string in the init shape and serialize to a protojson string. - Response decode is
fromJson(<Res>Schema, await resp.json(), { ignoreUnknownFields: true }). TheignoreUnknownFields: trueoption is MANDATORY (forward-compat with server-added fields). errors.tsstays exactly as today (sharedApiError/ValidationError, copied verbatim frominternal/tsclientgen/testdata/golden/errors.ts), imported with a.jsextension.- All relative imports use
.jsextensions (nodenext). - Headers / method / path /
AbortSignal/baseURL.replace(/\/+$/, "")/defaultHeadersspread 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 rawJSON.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.tsvia protoc-gen-es (target=ts) and, separately, the<proto>_client.tsmatching 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.tsis unchanged and remains the single shared root module.
