Skip to content

Latest commit

 

History

History
1214 lines (921 loc) · 49.8 KB

runtime_api.md

File metadata and controls

1214 lines (921 loc) · 49.8 KB

Protobuf-ES: Runtime API

The runtime library for the generated code is provided by the npm package @bufbuild/protobuf. This is a detailed overview of the features provided by the library.

Message class

All generated messages extend the base class Message. It provides a few helpful methods to compare, clone, and serialize, and a convenient constructor. All message classes also come with some static properties with metadata, and static convenience methods.

For the following examples, we will use the following message definition example.proto:

syntax="proto3";
package docs;

message User {
  string first_name = 1;
  string last_name = 2;
  bool active = 3;
  User manager = 4;
  repeated string locations = 5;
  map<string, string> projects = 6;
}

Constructing messages

You can create an instance with the new keyword:

const user = new User();

For convenience, constructors accept an initializer object. All fields in the initializer object are optional, and if not provided, the default value for the field is used.

const user = new User({
  firstName: "Homer",
  active: true,
  manager: {  // you can simply pass an initializer object for this message field
    lastName: "Burns",
  },
});

The initializer object accepted by all message constructors is of type PartialMessage<T> where T is your message type. So in the above example, the initializer object is of type PartialMessage<User>. PartialMessage is similar to the TypeScript built-in type Partial, but works recursively. For more details, see the below section on Advanced TypeScript types.

If you need to define the initializer object independent of the constructor, then be sure to use a type assertion, otherwise you may see unexpected compile errors with oneof fields. In TypeScript 4.9 and above, it is recommended to use satisfies.

const obj = {
  firstName: "Homer",
  active: true,
  manager: {  
    lastName: "Burns",
  },
} satisfies PartialMessage<User>;

const user = new User(obj);

If you are not using TypeScript 4.9 yet, use as const instead of satisfies PartialMessage<User>.

Default field values

Fields are automatically set to their default value if you create a message with the new keyword. You can see that in the TypeScript generated code, where fields with a default value are class properties with a default value:

/**
 * @generated from field: string firstName = 1;
 */
firstName = "";

Protobuf fields map to default values as follows:

Protobuf field Class property default value
bool false
string ""
other scalar types see scalar field default values
optional scalar undefined
message undefined
map {}
repeated []
oneof { case: undefined }

Accessing fields

Fields translate to plain properties. You can set and get field values with simple property access:

user.firstName = "Homer";
user.manager = new User();

user.firstName; // "Homer" 
user.manager?.active; // false 

You can also use a destructuring assignment:

let {firstName, lastName} = user;

The same is true for repeated and map fields, but fields in a oneof group are grouped into an object property.

Accessing oneof groups

With the following oneof group:

message User {
  string first_name = 1;
  string last_name = 2;
  bool active = 3;
  User manager = 4;
  repeated string locations = 5;
  map<string, string> projects = 6;
+ oneof result {
+   int32 number = 4;
+   string error = 5;
+ }
}

A property named result (the name of the oneof group) is added to the message. It is an object with two properties:

  • case - the name of the selected field
  • value - the value of the selected field

This property is always defined on the message - similar how map or repeated fields are always defined. By default, it is {case: undefined}.

In our example, result.case can be either "number", "error", or undefined. If a field is selected, the property result.value contains the value of the selected field.

In TypeScript, these rules are actually enforced by the declaration:

result:
  | { case: "number";  value: number }
  | { case: "error";   value: string }
  | { case: undefined; value?: undefined } = { case: undefined };

To select a field, simply replace the result object:

user.result = {case: "number", value: 123};
user.result = {case: undefined};

To query a oneof group, you can use if-blocks:

if (user.result.case === "number") {
  user.result.value; // a number
}

Or a switch statement:

switch (user.result.case) {
  case "number":
    user.result.value; // a number
    break;
  case "error":
    user.result.value; // a string
    break;
}

This representation is particularly useful in TypeScript, because the compiler narrows down the type. That means the if blocks and switch statements above tell the compiler the type of the value property. Note that type narrowing requires the TypeScript compiler option strictNullChecks. This option is automatically enabled with the option strict, which is recommended.

Cloning messages

While a shallow copy of a message can be created by using the spread operator with the message constructor, it is also possible to create a deep clone of a message:

user.clone();

Comparing messages

We provide instance methods as well as static methods to test if two messages of the same type are equal:

user.equals(user); // true
user.equals(null); // false
User.equals(user, user); // true 
User.equals(user, null); // false 
User.typeName; // docs.User

Serializing messages

All messages provide methods for serializing and parsing data between the binary and JSON formats. Conformance is ensured by the conformance test suite. Protobuf-ES does not implement the text format.

Here is an example that serializes a message to the binary format, and parses it again:

const user = new User({
    firstName: "Homer",
    active: true,
});

const bytes: Uint8Array = user.toBinary();
User.fromBinary(bytes);

The toBinary and fromBinary methods each take an optional argument with serialization options.

Serializing to JSON can be done in a similar fashion:

import type { JsonValue } from "@bufbuild/protobuf";

const json: JsonValue = user.toJson();
User.fromJson(json);

JsonValue is a type that accurately represents any possible value in JSON. It can safely be serialized to a string with JSON.stringify. For convenience, we also provide methods that include the stringify step:

// Same as JSON.stringify(user.toJson());
const json: string = user.toJsonString();

// Same as User.fromJson(JSON.parse(json));
User.fromJsonString(json);

The JSON format comes with several options. Note that if you are using google.protobuf.Any or extensions, you have to provide a registry with the option typeRegistry.

As a general guide to decide between the binary format and JSON: The JSON format is great for debugging, but the binary format is more resilient to changes. For example, you can rename a field, and still parse binary data serialized with the previous version. In general, the binary format is also more performant than JSON.

To learn about serialization options and other details related to serialization, see the section about advanced serialization.

Identifying messages

To check whether a given object is a message, use the function isMessage.

isMessage is mostly equivalent to the instanceof operator. For example, isMessage(foo, MyMessage) is the same as foo instanceof MyMessage, and isMessage(foo) is the same as foo instanceof Message.

The advantage of isMessage is that it compares identity by the message type name, not by class identity. This makes it robust against the dual package hazard and similar situations, where the same message is duplicated.

To determine if an object is any subtype of Message, pass that object to the function. To determine if an object is a specific type of Message, pass the object as well as the type.

import { isMessage } from "@bufbuild/protobuf";

const user = new User({
    firstName: "Homer",
});

isMessage(user);                    // true
isMessage(user, User);              // true
isMessage(user, OtherMessageType);  // false

Enumerations

For enumerations, we lean on TypeScript enums. A quick refresher about them:

  • It is possible to look up the name for an enum value:
    let val: MyEnum = MyEnum.FOO;
    let name = MyEnum[val]; // => "FOO"
  • and to look up an enum value by name:
    let val: MyEnum = MyEnum["FOO"];
  • TypeScript enums are just plain objects in JavaScript.
  • TypeScript enums support aliases - as does protobuf with the allow_alias option.

Note that in Protobuf-ES, all enums are "open", meaning that old generated code can contain a value in an enum field that was added in a new version of the schema. With TypeScript v5 and later, enums are closed in the type system. With earlier versions of TypeScript, they are open.

Extensions

Extensions can be set on a message using the setExtension function. Provided we have the following message and extension:

syntax = "proto2";

message User {
  extensions 100 to 200;
}

extend User {
  optional uint32 age = 100;
}

You can set the extension field age like this:

import { setExtension } from "@bufbuild/protobuf";
import { User, age } from "./example_pb.js";

const user = new User();
setExtension(user, age, 77);

If the message already has a value for the age extension, the value is replaced. You can remove an extension from a message with the function clearExtension. To retrieve an extension value, use getExtension. To check whether an extension is set, use hasExtension.

import { setExtension, getExtension, hasExtension, clearExtension } from "@bufbuild/protobuf";

setExtension(user, age, 77);
hasExtension(user, age); // true
getExtension(user, age); // 77
clearExtension(user, age);
hasExtension(user, age); // false

Note that getExtension never returns undefined. If the extension is not set, hasExtension returns false, but getExtension returns the default value, for example 0 for numeric types, [] for repeated fields, and an empty message instance for message fields.

Extensions are stored as unknown fields on a message . If you retrieve an extension value, it is deserialized from the binary unknown field data. To mutate a value, make sure to store the new value with setExtension after mutating. For example, let's say we have the extension field repeated string hobbies = 101, and want to add values:

import { setExtension, getExtension, hasExtension, clearExtension } from "@bufbuild/protobuf";
import { hobbies } from "./example_pb.js";

const h = getExtension(user, hobbies);
h.push("Baking");
h.push("Pottery");

setExtension(user, hobbies, h);

Extensions and JSON

If you parse or serialize a message to binary, extensions are automatically included, since they are stored as unknown fields. If you parse or serialize a message to JSON, you have to provide a registry with the extensions you want to include, similar to the well-known type Any.

In the following example, we use the serialization option typeRegistry to provide extensions:

import { createRegistry } from "@bufbuild/protobuf";
import { age, hobbies } from "./example_pb.js";

const typeRegistry = createRegistry(age, hobbies);

user.toJsonString({ typeRegistry }); // {"[age]":77,"[hobbies]":["Baking","Pottery"]}

Extensions and custom options

Extension are commonly used for custom options, which allow to annotate elements in a Protobuf file with arbitrary information.

Custom options are extensions to the google.protobuf.*Options messages defined in google/protobuf/descriptor.proto. When a Protobuf compiler parses a file, it converts all elements into descriptors, and sets custom option values on the option message field of the corresponding descriptor.

When a plugin is invoked to generate code, it receives the parsed descriptors, and the plugin can read the custom option value using the extension. To see how this works in practice, take a look at the example in our guide for writing plugins.

At this point in time, it is not possible to retrieve custom options from generated code, since Protobuf-ES does not embed the full descriptors in the generated code.

Well-known types

Protocol buffers have a small standard library of well-known types. @bufbuild/protobuf provides all of them as pre-compiled exports.

Expand the list of Well-known types
Name Type Source
Any message google/protobuf/any.proto
Api message google/protobuf/api.proto
BoolValue message google/protobuf/wrappers.proto
BytesValue message google/protobuf/wrappers.proto
DoubleValue message google/protobuf/wrappers.proto
Duration message google/protobuf/duration.proto
Empty message google/protobuf/empty.proto
Enum message google/protobuf/type.proto
EnumValue message google/protobuf/type.proto
Field message google/protobuf/type.proto
Field_Cardinality enum google/protobuf/type.proto
Field_Kind enum google/protobuf/type.proto
FieldMask message google/protobuf/field_mask.proto
FloatValue message google/protobuf/wrappers.proto
Int32Value message google/protobuf/wrappers.proto
Int64Value message google/protobuf/wrappers.proto
ListValue message google/protobuf/struct.proto
Method message google/protobuf/type.proto
Mixin message google/protobuf/type.proto
NullValue enum google/protobuf/struct.proto
Option message google/protobuf/type.proto
SourceContext message google/protobuf/source_context.proto
StringValue message google/protobuf/wrappers.proto
Struct message google/protobuf/struct.proto
Syntax enum google/protobuf/type.proto
Timestamp message google/protobuf/timestamp.proto
Type message google/protobuf/type.proto
UInt32Value message google/protobuf/wrappers.proto
UInt64Value message google/protobuf/wrappers.proto
Value message google/protobuf/struct.proto

Note that this list does not include google/protobuf/descriptors.proto, but @bufbuild/protobuf exports all types defined in this file as well.

Some of the well-known types provide additional methods for convenience:

Timestamp

import { Timestamp } from "@bufbuild/protobuf";

// Create an instance from a built-in Date object
let ts = Timestamp.fromDate(new Date(1938, 0, 10));

// Create an instance with the current time
ts = Timestamp.now()

// Convert to a built-in Date object
ts.toDate();

Any

import { Any } from "@bufbuild/protobuf";
import { Timestamp } from "@bufbuild/protobuf";

// Pack a message:
let any = Any.pack(user);
any.typeUrl; // type.googleapis.com/docs.User

// Check what an Any contains:
any.is(User); // true
any.is(Timestamp); // false

// Unpack an Any by providing a blank instance:
let user = new User();
any.unpackTo(user); // true

// Alternative: Unpack an Any using a type registry:
const typeRegistry = createRegistry(User, Timestamp);
any.unpack(typeRegistry); // Message of type User

let ts = new Timestamp();
any.unpackTo(ts); // false, you provided an instance of the wrong type

Any stores the message as binary data. To parse or serialize Any to JSON, you need to provide a registry, similar to extensions.

import { Any, createRegistry, Timestamp } from "@bufbuild/protobuf";

// Pack a Timestamp message in an Any:
const timestamp = Timestamp.now();
const any = Any.pack(timestamp);

// Create a registry so that the Timestamp type can be looked up and converted
// to JSON during serialization:
const typeRegistry = createRegistry(Timestamp);
any.toJsonString({ typeRegistry });

Struct

google.protobuf.Struct can represent anything JSON can represent. But it is a bit cumbersome to construct a Struct:

let struct = new Struct({
  fields: {
    a: {
      kind: { case: "numberValue", value: 123 },
    },
    b: {
      kind: { case: "stringValue", value: "abc" },
    },
  },
});

struct.toJsonString(); // "{a: 123, b: \"abc\"}"

We recommend to use fromJson() to construct Struct literals:

let struct = Struct.fromJson({
  a: 123,
  b: "abc",
});

64-bit integral types

We use the bigint primitive to represent 64-bit integral types, because JavaScript Number cannot represent the full range of 64-bit numbers.

For the following field definitions:

message User {
  string first_name = 1;
  string last_name = 2;
  bool active = 3;
  User manager = 4;
  repeated string locations = 5;
  map<string, string> projects = 6;
+ uint64 ulong = 4;
+ int64 long = 5;
}

You can use bigint as expected:

user.ulong = 123n;
user.long = -123n;

user.ulong + 1n; // 124n
user.long + 1n; // -122n

With the built-in field option jstype = JS_STRING, 64-bit integral fields will use string instead of bigint:

  int64 long = 5 [jstype = JS_STRING]; // will generate `long: number`

bigint in unsupported environments

If bigint is not available in your environment, you can still serialize and deserialize messages with 64-bit integral fields without losing any data. But Protobuf-ES will convert those numbers into string. That means you can always call toString() on a bigint field, and will always receive a string representation that is suitable to display in a GUI, as a map key, or for similar purposes.

In case you simply want to set a field value, for example from an HTML form input, use the provided conversion utility protoInt64:

import { protoInt64 } from "@bufbuild/protobuf";

let input: string | number | bigint = "123";

user.long  = protoInt64.parse(input);
user.ulong = protoInt64.uParse(input);

If you want to perform arithmetic on bigint fields, you will need to use a third party library like Long.js.

Reflection

One of the strong points of Protobuf are its reflection capabilities. In the following sections, we will take a look at the concepts, and how they are implemented in Protobuf-ES.

Descriptors

Descriptors describe Protobuf definitions. Every Protobuf compiler parses source files into descriptors, which are protobuf messages themselves.

For example, the command buf build proto --output set.binpb compiles all Protobuf files in the directory proto, and writes the message google.protobuf.FileDescriptorSet to the file set.binpb.

The message google.protobuf.FileDescriptorSet is defined in google/protobuf/descriptor.proto, along with other messages and ancillary types describing every element of Protobuf source. Protobuf-ES provides all descriptor messages as exports from @bufbuild/protobuf, along with all other well-known types.

For a simple example, the following script will read and parse the file created by the compiler command from above, and print the name of each Protobuf file:

import { FileDescriptorSet } from "@bufbuild/protobuf";
import { readFileSync } from "node:fs";

const set = FileDescriptorSet.fromBinary(
  readFileSync("./set.binpb"),
);

for (const file of set.file) {
  console.log(file.name);
}

You can find a deeper dive into the model in Buf's reference about descriptors.

Similar to several other Protobuf implementations, Protobuf-ES provides wrapper types for the Protobuf descriptor messages that avoid many of their quirks: The function createDescriptorSet from @bufbuild/protobuf takes a google.protobuf.FileDescriptorSet as an input, and returns a DescriptorSet object. This object contains an array of all files, and map collections for all top-level types in a convenient wrapped form.

The following table shows how Protobuf descriptor messages map to their wrapped counterparts:

Protobuf message google.protobuf. Interface from @bufbuild/protobuf
FileDescriptorProto DescFile
DescriptorProto DescMessage
FieldDescriptorProto DescField, DescExtension
OneofDescriptorProto DescOneof
EnumDescriptorProto DescEnum
EnumValueDescriptorProto DescEnumValue
ServiceDescriptorProto DescService
MethodDescriptorProto DescMethod

If you write a Protobuf plugin with our framework @bufbuild/protoplugin, you'll see that it provides the wrapped types for the schema to generate. You can find concrete examples in our guide for writing plugins.

Reflection at runtime

Many Protobuf implementations embed descriptors in the generated code so that they are available for reflection. For example, custom options can typically be retrieved from the descriptor at runtime.

Protobuf-ES does not embed full descriptors in the generated code, but a very minimal set of information. The information is sufficient to walk over all fields of a message and access values. Serialization and many other operations in Protobuf-ES are implemented using this information, and you can use it for your own purposes. The entry points are Message types and Enum types.

The following example shows how to iterate over the fields of an arbitrary message:

function walkFields(message: AnyMessage) {
  for (const fieldInfo of message.getType().fields.byNumber()) {
    const value = message[fieldInfo.localName];
    console.log(`field ${fieldInfo.localName}: ${value}`);
  }
}

walkFields(user);
// field firstName: Homer
// field lastName: Simpson
// field active: true
// field manager: undefined
// field locations: SPRINGFIELD
// field projects: {"SPP":"Springfield Power Plant"}

For a more practical example that covers all cases, you can take a look at the source of toPlainMessage.

Message types

We gave an overview of the message class earlier. Besides the attributes listed there, message classes actually come with a few more static properties. The static shape of the generated class is a MessageType, a representation of the type of a message.

Such a type can actually be created at run time. We can take a peek at the generated code to get some insights:

class User extends Message<User> {
  //...
  static readonly runtime = proto3;
  static readonly typeName = "docs.User";
  static readonly fields: FieldList = proto3.util.newFieldList(() => [
    { no: 1, name: "first_name", kind: "scalar", T: 9 /* ScalarType.STRING */ },
    { no: 2, name: "last_name", kind: "scalar", T: 9 /* ScalarType.STRING */ },
    { no: 3, name: "active", kind: "scalar", T: 8 /* ScalarType.BOOL */ },
    { no: 4, name: "manager", kind: "message", T: User },
    { no: 5, name: "locations", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true },
    { no: 6, name: "projects", kind: "map", K: 9 /* ScalarType.STRING */, V: {kind: "scalar", T: 9 /* ScalarType.STRING */} },
  ]);

We can observe three properties here:

  1. runtime – will be either proto2 or proto3, depending on the syntax of the source file.
  2. typeName – the fully qualified name of the message, constructed from the package name, a dot, and the original name of the message.
  3. fields – all fields of the message are listed here, together with their field number, name and type.

This is actually all the information we need to re-create this message type at run time:

const User = proto3.makeMessageType(
  "docs.User",
  () => [
    { no: 1, name: "first_name", kind: "scalar", T: 9 /* ScalarType.STRING */ },
    { no: 2, name: "last_name", kind: "scalar", T: 9 /* ScalarType.STRING */ },
    { no: 3, name: "active", kind: "scalar", T: 8 /* ScalarType.BOOL */ },
    { no: 4, name: "manager", kind: "message", T: User },
    { no: 5, name: "locations", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true },
    { no: 6, name: "projects", kind: "map", K: 9 /* ScalarType.STRING */, V: {kind: "scalar", T: 9 /* ScalarType.STRING */} },
  ],
);

The resulting User is completely equivalent to the generated TypeScript class. In fact, this exact piece of code is generated with the plugin option target=js, because if saves us quite a bit of code size.

Enum types

Similar to messages, enumerations also include Protobuf metadata. Since TypeScript enumerations cannot be extended with methods, EnumType is stored as a symbol property on the enum object. It can be retrieved with the utility proto3.getEnumType().

EnumType provides the fully qualified name in Protobuf, and methods to find values by name or integer value.

Here is an example program that prints metadata for a generated enumeration:

import { proto3 } from "@bufbuild/protobuf";
import { MyEnum } from "./generated";

const enumType = proto3.getEnumType(MyEnum);

console.log(enumType.typeName);
for (const value of enumType.values) {
  console.log("integer value:", value.no);
  console.log("protobuf name:", value.name);
  console.log("name in generated code:", value.localName);
}

Similar to messages, enumerations can also be created at runtime, via proto3.makeEnum().

Registries

There are some situations where you may want the ability to look up a message class or other type by type name. For example, google.protobuf.Any stores an arbitrary message as a type name and binary data.

For this purpose, Protobuf-ES has registries: A simple interface that lets you find a type by name. You use the convenient function createRegistry to create one. It takes any of the generated types in the arguments. For example:

import { createRegistry } from "@bufbuild/protobuf";
import { MessageA, MessageB } from "./generated"

const registry = createRegistry(
  MessageA, 
  MessageB,
);

const messageType = registry.findMessage("foo.MessageA");
if (messageType) {
  const instance = new messageType();
}

Registries are also used during JSON serialization: To convert google.protobuf.Any and extensions to their JSON representation and back, the registry is necessary to look up types by name. You can provide a registry in the serialization option typeRegistry.

Dynamic messages

Protobuf-ES can create a registry from generated code with createRegistry, but it can also create a registry from descriptors with the function createRegistryFromDescriptors.

When a message, enumeration, or extension is retrieved from this registry, the type is created dynamically at runtime. The dynamic types are functionally identical to generated code, and can interact with message data the same way. Dynamic messages are used to interact with message data for types that are not known at compile time.

As an example, let's write a small tool that converts Protobuf message data from the binary format to JSON:

import { createRegistryFromDescriptors } from "@bufbuild/protobuf";
import { readFileSync } from "node:fs";

// The first argument is the path to a `google.protobuf.FileDescriptorSet`,
// the second argument is the fully qualified type name of a message in the set
if (process.argv.length !== 4) {
  console.error(`Usage: ${process.argv[1]} path-to-file-descriptor-set message-type-name`);
  process.exit(1);
}
const [, , schemaFile, messageTypeName] = process.argv;

// Create the message type at runtime
const registry = createRegistryFromDescriptors(readFileSync(schemaFile));
const messageType = registry.findMessage(messageTypeName);
if (!messageType) {
  console.error(`failed to find message type ${messageTypeName}`);
  process.exit(1);
}

// Read the binary message data from stdin
const message = messageType.fromBinary(readFileSync(0));

// Write the message as JSON to stdout
console.log(message.toJsonString({
  typeRegistry: registry,
  prettySpaces: 2,
}));

Advanced serialization

Binary serialization options

Options for Message.toBinary:

  • writeUnknownFields?: boolean
    By default, unknown fields are included in the serialized output. Setting this option to false changes the behavior to elide unknown fields.
  • writerFactory?: () => IBinaryWriter
    A function for specifying a custom implementation to encode binary data.

Options for Message.fromBinary:

  • readUnknownFields?: boolean
    By default, unknown fields are retained during parsing. Setting this option to false changes the behavior to ignore unknown fields.
  • readerFactory?: (bytes: Uint8Array) => IBinaryReader
    A function for specifying a custom implementation to decode binary data.

JSON serialization options

Options for Message.fromJson and Message.fromJsonString:

  • ignoreUnknownFields?: boolean
    By default, unknown properties are rejected. This option overrides this behavior and ignores properties, as well as unrecognized enum string representations.
  • typeRegistry?: IMessageTypeRegistry & Partial<IExtensionRegistry>
    A registry to parse extensions and google.protobuf.Any from JSON.

Options for Message.toJson and Message.toJsonString:

  • emitDefaultValues?: boolean
    Fields with default values are omitted by default in JSON output. This option overrides this behavior and outputs fields with their default values.
  • enumAsInteger?: boolean
    The name of an enum value is used by default in JSON output. This option overrides the behavior to use the numeric value of the enum value instead.
  • useProtoFieldName?: boolean
    Field names are converted to lowerCamelCase by default in JSON output. This option overrides the behavior to use the proto field name instead.
  • typeRegistry?: IMessageTypeRegistry & Partial<IExtensionRegistry>
    A registry to convert extensions and google.protobuf.Any to JSON.
  • prettySpaces?: boolean
    Only available with toJsonString. A convenience property for the space option to JSON.stringify, which controls indentation for prettier output. See the JSON.stringify docs.

JSON.stringify

Besides toJson and toJsonString, messages also have a toJSON method that is used by JSON.stringify. See the documentation on MDN for details on how it works.

We implement this method to ensure that Protobuf messages are always serialized with the canonical JSON format. Otherwise, JSON.stringify() would crash on BigInt values, and would not serialize oneof, enumerations, and other types correctly.

The toJSON method is marked as protected since you should never need to invoke this function directly.

Serializing a message with JSON.stringify() is equivalent to calling toJsonString on the message, with the serialization option emitDefaultValues: true.

Unknown fields

When binary message data is parsed, fields that the parser does not recognize are preserved. They are stored on the message as unknown fields, and will be included when the message is serialized again.

This default behavior can be modified with the binary serialization options readUnknownFields and writeUnknownFields.

Note that extension values are also stored as unknown fields.

Size-delimited message streams

Protobuf-ES supports the size-delimited format for messages. It lets you serialize multiple messages to a stream, and parse multiple messages from a stream.

A size-delimited message is a varint size in bytes, followed by exactly that many bytes of a message serialized with the binary format. This implementation is compatible with the counterparts in C++, Java, Go, and others.

The export protoDelimited provides a method to serialize such a size-delimited message:

import { protoDelimited } from "@bufbuild/protobuf";

const bytes = protoDelimited.enc(new User({ firstName: "John" }));
const user = protoDelimited.dec(User, bytes);

To parse size-delimited messages from a stream, the export provides the method decStream. The method expects an AsyncIterable<Uint8Array> as a stream input, so it works with Node.js streams out of the box, and can be easily adapted to other stream APIs:

import { protoDelimited } from "@bufbuild/protobuf";
import { createReadStream, createWriteStream } from "fs";
import { tmpdir } from "os";
import { join } from "path";

// Let's write a couple of messages to a file
const ws = createWriteStream("protoDelimited.bin", {encoding: "binary"});
ws.write(protoDelimited.enc(new User({ firstName: "John" })));
ws.write(protoDelimited.enc(new User({ firstName: "Max" })));
ws.write(protoDelimited.enc(new User({ firstName: "Max" })));
ws.end();
ws.close();

// Now we can parse them from the stream
const readStream = createReadStream("protoDelimited.bin");
for await (const user of protoDelimited.decStream(User, readStream)) {
  console.log(user);
}

Binary encoding

At a low level, the Protobuf binary serialization is implemented with the classes BinaryReader and BinaryWriter. They implement the primitives of the Protobuf binary encoding.

Both classes are part of the public API and can be used on their own. The following example uses BinaryWriter to serialize valid data for our example message:

import { BinaryWriter } from "@bufbuild/protobuf";
import { User } from "./generated";

const bytes = new BinaryWriter()
  // string first_name = 1
  .tag(1, WireType.LengthDelimited)
  .string("Homer")
  // bool active = 3
  .tag(3, WireType.Varint)
  .bool(true)
  .finish();

const user = User.fromBinary(bytes);
user.firstName; // "Homer"
user.active; // true

Internally, the classes use TextEncoder and TextDecoder from the text encoding API to encode and decode text as UTF-8. In an environment where this API is unavailable, your need to bring your own UTF-8 encoder. To do so, you can use the serialization options writerFactory and readerFactory to provide your own implementation.

Base64 encoding

Unfortunately, there is no convenient standard API for base64 encoding in ECMAScript, but it can be very useful when transmitting binary data.

The export protoBase64 provides methods to encode and decode base64:

import { protoBase64 } from "@bufbuild/protobuf";
import { User } from "./generated";

const user = new User({ firstName: "Joe" });
const bytes: Uint8Array = user.toBinary();
const base64: string = protoBase64.enc(bytes)

Advanced TypeScript types

PartialMessage

This type is well suited in case you know the type of a message, but want to allow an instance to be given in the most flexible way. If you want to offer an API that lets users provide message data, consider accepting PartialMessage<T>, so that users can simply give an object literal with only the non-default values they want. Note that any T is assignable to PartialMessage<T>.

For example, let's say you have a protobuf message User, and you want to provide a function to your users that processes this message:

export function sendUser(user: PartialMessage<User>) {
  // convert partial messages into their full representation if necessary
  const u = isMessage(user, User) ? user : new User(user);
  // process further...
  const bytes = u.toBinary();
}

All three examples below are valid input for your function:

sendUser({firstName: "Homer"});

const u = new User();
u.firstName = "Homer";
sendUser(u);

sendUser(new User());

PlainMessage

PlainMessage<T> represents just the fields of a message, without their methods.

In contrast to PartialMessage, PlainMessage requires all properties to be provided. For example:

let plain: PlainMessage<User> = {
  firstName: "Homer",
  lastName: "Simpson",
  active: true,
  manager: undefined,
  locations: [],
  projects: {}
};

As such, PlainMessage<T> can be a great fit to use throughout your business logic, if that business logic is never concerned with serialization, and does not need instanceof.

Note that any T (assuming T extends Message) is assignable to a variable of type PlainMessage<T>.

AnyMessage

If you want to handle messages of unknown type, the type AnyMessage provides a convenient index signature to access fields:

const anyMessage: AnyMessage = user;
user["firstName"];

Note that any message is assignable to AnyMessage.