@bufbuild/protobuf
@bufbuild/protobuf is the runtime half of Protobuf-ES, a Protocol Buffers implementation for JavaScript and TypeScript. You write .proto files, run the protoc-gen-es plugin through the Buf CLI or protoc, and get generated files containing a plain TypeScript type per message plus a schema object describing it. The runtime then provides functions that take a schema and a value: create builds a message with defaults filled in, toBinary and fromBinary handle the wire format, toJson and fromJson handle the canonical JSON mapping, and clone, equals, and merge do the obvious things. Messages are plain objects with plain properties, not class instances with getters and setters, which is the main departure from google-protobuf. It passes the official Protobuf conformance suite, supports proto2, proto3, and Editions, and ships the well-known types, reflection, registries, and extension support in separate entry points so you only pay for what you import.
If you are writing new TypeScript against Protobuf schemas, this is the implementation to pick: plain-object messages, conformance-tested serialization, and a small runtime. The one thing it deliberately does not do is read .proto files at runtime, so dynamic-schema use cases still belong to protobufjs.
Use it if
- You are consuming a Protobuf or Connect API from TypeScript and want generated types that behave like ordinary objects, so spreading, destructuring, and structural typing all work as expected
- Wire-format correctness matters to you: it is verified against the official conformance suite, including the JSON mapping and Editions, which several JavaScript implementations only partially handle
- You are shipping to browsers and care about bytes: the runtime is around 24 KB gzipped with no dependencies, and the entry points for well-known types, reflection, and text format are separate so unused parts drop out
- You are already using Connect or gRPC-Web through connect-es, which is built on this runtime and expects its generated schema objects
- You need to load .proto files at runtime: there is no parser in the runtime, so schemas come from a code generation step or a precompiled descriptor set. protobufjs can read a .proto file in the browser and build message types on the fly, and this cannot
- You do not want a code generation step in your build: buf generate (or protoc) has to run, generated files have to be committed or generated in CI, and every schema change means regenerating before TypeScript compiles
- You are on the v1 API: v2 replaced classes and methods with schema-plus-function calls, so user.toBinary() became toBinary(UserSchema, user) everywhere. v1 lives on the legacy dist-tag and the migration is mechanical but touches every call site
- Your server stack is @grpc/grpc-js with grpc-tools: that ecosystem generates google-protobuf-style classes, and mixing the two representations in one service means converting at the boundary
- You are stuck on an old toolchain: the stated support policy covers TypeScript versions less than two years old and browsers baseline for the last 2.5 years, so ES5 targets and elderly bundlers are not a supported configuration
Setup reality
The runtime install is trivial (npm install @bufbuild/protobuf, zero dependencies), and the work is entirely in the generation step. You add @bufbuild/protoc-gen-es and usually @bufbuild/buf as dev dependencies, write a buf.gen.yaml pointing at your proto directory, and run npx buf generate. The default plugin target is js+dts rather than ts, which surprises people who expected .ts files and then find generated JavaScript in their source tree; pass opt: target=ts if that is what you want. import_extension defaults to none, which breaks Node ESM resolution unless you set it to js, and that mismatch produces module-not-found errors that look like a bundler problem rather than a codegen option. Generated files import from @bufbuild/protobuf/codegenv2, so the runtime version and the plugin version have to move together; a stale plugin against a newer runtime fails at import time. Well-known types live at @bufbuild/protobuf/wkt, reflection at /reflect, and text format at /txtpb, so a missing helper usually means you imported from the root entry point instead of a subpath. Sixty-four bit fields come back as bigint, which JSON.stringify refuses to serialize, so use toJson rather than the built-in serializer.
Patterns
Generate TypeScript from .proto filesgenerate-types
# buf.gen.yaml
version: v2
inputs:
- directory: proto
plugins:
- local: protoc-gen-es
out: src/gen
opt:
- target=ts
- import_extension=js
# then
npm install @bufbuild/protobuf
npm install --save-dev @bufbuild/protoc-gen-es @bufbuild/buf
npx buf generateWithout target=ts you get js plus d.ts files, which is the default because it bundles smaller. Set import_extension=js if the generated code will run under Node ESM, otherwise the extensionless imports fail to resolve.
Build a message valuecreate-a-message
import { create } from "@bufbuild/protobuf";
import { UserSchema, type User } from "./gen/user/v1/user_pb.js";
const user: User = create(UserSchema, {
id: "123",
firstName: "Alice",
});
user.lastName = "Smith"; // plain property, no settercreate fills in defaults and the internal $typeName field. You can hand-write the object literal instead, but then TypeScript will demand $typeName and you lose default population.
Serialize and parse the wire formatbinary-roundtrip
import { toBinary, fromBinary } from "@bufbuild/protobuf";
import { UserSchema } from "./gen/user/v1/user_pb.js";
const bytes: Uint8Array = toBinary(UserSchema, user);
const parsed = fromBinary(UserSchema, bytes);Every function takes the schema first and the value second; there are no methods on the message. fromBinary throws on malformed input, so wrap it when parsing anything that came off a network.
Convert to and from canonical JSONjson-roundtrip
import { toJson, toJsonString, fromJson } from "@bufbuild/protobuf";
import { UserSchema } from "./gen/user/v1/user_pb.js";
const obj = toJson(UserSchema, user); // JsonValue
const text = toJsonString(UserSchema, user); // string
const back = fromJson(UserSchema, obj);Do not reach for JSON.stringify on a message: 64-bit fields are bigint and stringify throws on them, and field names would not be mapped to the canonical lowerCamelCase form. toJson handles both.
Work with 64-bit integersint64-fields
import { protoInt64 } from "@bufbuild/protobuf";
import { create } from "@bufbuild/protobuf";
import { InvoiceSchema } from "./gen/billing/v1/invoice_pb.js";
const invoice = create(InvoiceSchema, {
amountCents: protoInt64.parse("9007199254740993"),
});
const asString = invoice.amountCents.toString();int64, uint64, sint64, fixed64 and sfixed64 all surface as bigint. protoInt64.parse validates range, which a bare BigInt() call does not. Canonical JSON encodes these as strings, so round-tripping through toJson is safe.
Tell an unset field from a zero valuefield-presence
import { isFieldSet, clearField } from "@bufbuild/protobuf";
import { UserSchema } from "./gen/user/v1/user_pb.js";
if (isFieldSet(user, UserSchema.field.nickname)) {
console.log(user.nickname);
}
clearField(user, UserSchema.field.nickname);For plain proto3 scalar fields there is no presence at all and an empty string is indistinguishable from unset. Mark the field optional in the .proto if you need this distinction; then the property becomes T | undefined.
Copy, compare, and combine messagesclone-equals-merge
import { clone, equals, merge } from "@bufbuild/protobuf";
import { UserSchema } from "./gen/user/v1/user_pb.js";
const copy = clone(UserSchema, user);
const same = equals(UserSchema, user, copy); // true
merge(UserSchema, copy, patch); // mutates copy in placeequals compares by Protobuf semantics, not reference, which structuralClone-based comparisons get wrong for bigint and Uint8Array fields. merge follows Protobuf merge rules: repeated fields are appended, not replaced.
Convert between Timestamp and Datetimestamps
import { create } from "@bufbuild/protobuf";
import { timestampNow, timestampFromDate, timestampDate } from "@bufbuild/protobuf/wkt";
import { EventSchema } from "./gen/event/v1/event_pb.js";
const event = create(EventSchema, {
createdAt: timestampFromDate(new Date("2026-01-01")),
seenAt: timestampNow(),
});
const js: Date = timestampDate(event.createdAt!);These helpers live at the /wkt subpath, not the package root. Timestamp keeps seconds as bigint and nanos as number, so converting through Date silently drops sub-millisecond precision.
Put an arbitrary message inside an Anyany-pack-unpack
import { createRegistry } from "@bufbuild/protobuf";
import { anyPack, anyIs, anyUnpack } from "@bufbuild/protobuf/wkt";
import { UserSchema } from "./gen/user/v1/user_pb.js";
const packed = anyPack(UserSchema, user);
if (anyIs(packed, UserSchema)) {
const back = anyUnpack(packed, UserSchema);
}
// or resolve dynamically
const registry = createRegistry(UserSchema);
const unknown = anyUnpack(packed, registry);Unpacking without knowing the type needs a registry containing that message, and anyUnpack returns undefined when the type is missing rather than throwing. The same registry is required for toJson on messages containing Any.
Read and write proto2 extensionsextensions
import { hasExtension, getExtension, setExtension } from "@bufbuild/protobuf";
import { priority_ext } from "./gen/ext/v1/ext_pb.js";
if (hasExtension(msg, priority_ext)) {
console.log(getExtension(msg, priority_ext));
}
setExtension(msg, priority_ext, 5);Extension values are not stored as normal properties, so they survive round trips only if the extension is registered where you parse. Custom options on descriptors use the parallel getOption and hasOption functions.
Build a registry from a descriptor setregistry-from-descriptors
import { fromBinary, createFileRegistry } from "@bufbuild/protobuf";
import { FileDescriptorSetSchema } from "@bufbuild/protobuf/wkt";
import { readFileSync } from "node:fs";
const set = fromBinary(FileDescriptorSetSchema, readFileSync("image.binpb"));
const registry = createFileRegistry(set);
const schema = registry.getMessage("user.v1.User");This is the closest thing to runtime schema loading: generate a descriptor set with buf build ahead of time and load that. It still does not parse .proto text, and messages resolved this way are reflection-only, without generated TypeScript types.
Call a service with the generated schemaconnect-client
import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import { UserService } from "./gen/user/v1/user_pb.js";
const client = createClient(
UserService,
createConnectTransport({ baseUrl: "http://localhost:8080" }),
);
const res = await client.getUser({ id: "123" });Service descriptors come from the same generated file as the messages, so no extra plugin is needed for Connect. Request arguments are accepted as plain object literals; the client calls create for you.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| protobufjs | npm | You need to parse .proto files at runtime or load descriptors dynamically instead of generating code ahead of time |
| ts-proto | npm | You want generated code shaped for a specific stack such as NestJS or @grpc/grpc-js, with heavy per-project codegen options |
| @connectrpc/connect | npm | You need the RPC layer on top of these messages: typed clients and servers over Connect, gRPC, and gRPC-Web |
| google-protobuf | npm | You are maintaining older code generated by protoc's JavaScript output and are not ready to regenerate |