bufbuild / protobuf-es

Protocol Buffers for ECMAScript. The only JavaScript Protobuf library that is fully-compliant with Protobuf conformance tests.
Apache License 2.0
1.14k stars 68 forks source link

How to extend generated types with methods in v2? #996

Closed renkei closed 4 days ago

renkei commented 1 week ago

With v1, the generated code was based on classes. I used the prototype-property to add additional convenience methods instead of writing a wrapper class. To make the methods which were added at runtime also available to the TypeScript compiler I used the declare module approach where I created an related interface.

This way, it was very easy to write extension methods that simplified the usage of the received messages without writing wrapper classes or external-like functions that need the message as argument.

Example with v1

// Component.proto

message Component
{
    string symbol = 1;
}

message SubComponent
{
    string symbol = 1;
    Component parent = 2;
}
// Component.ts

import * as Component from 'generated/Component_pb.js'

declare module 'generated/Component_pb.ts' {
    interface Component {
        /**
         * A convenience method to simplify usage of received message
         */
        getFullSymbol(host: string): string
    }

    interface SubComponent {
        /**
         * A convenience method to simplify usage of received message
         */
        getFullSymbol(host: string): string
    }
}

Component.Component.prototype.getFullSymbol = function(host: string): string {
    return `${host} / ${this.symbol}`
}

Component.SubComponent.prototype.getFullSymbol = function(host: string): string {
    return `${this.parent.getFullSymbol(host)} / ${this.symbol}`
}

export { Component }

Is this or something similar possible with v2 with the concept of plain objects and schemas?

timostamm commented 4 days ago

Adding methods is not possible, unfortunately. The equivalent is to create a function. For example:

import type { User, Robot } from "./gen/example_pb.js";

export function getFullName(m: User | Robot): string {
  switch (m.$typeName) {
    case "example.User":
      return `${m.firstName} ${m.lastName}`;
    case "example.Robot":
      return `${m.name}`;
  }
}

You don't need the schema, and you can support multiple messages with a type union, and can narrow the type with the $typeName property.

It's certainly a different style, but should work just as well in practice. It's not as self-documenting as a method, but on the other hand it's tree-shakeable.

renkei commented 4 days ago

Unfortunately, that's a pretty big restriction. We use the possibility to add some helper functions to the generated code in C# (classes are partial) and TypeScript for a long time. In TypeScript we used the google-protobuf npm package first, later we switched to your @bufbuild/protobuf v1 package. Now, with v2 this isn't possible anymore :-(

Of course, additional functions defined somewhere else is a workaround, but not so easy-to-use than a class member function and an additional significant breaking change in our code base. In the end, our approaches to implementing such extension methods will be much more divergent in our related programming languages with v2.

However, thanks for your answer and for your great job with this project! Any ideas, how long v1 will still be supported?

timostamm commented 4 days ago

It was not an easy decision to remove classes. Attaching behavior to objects with methods is a good fit for Protobuf. Unfortunately, support for classes in major frontend frameworks is very poor. It would be fantastic to have C#-style extension methods in TypeScript.

We don't have plans to stop supporting v1.