Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding Explicit Type Definitions to Factory Method Objects #1080

Closed
sam-step opened this issue Jul 30, 2024 · 6 comments · Fixed by #1104
Closed

Adding Explicit Type Definitions to Factory Method Objects #1080

sam-step opened this issue Jul 30, 2024 · 6 comments · Fixed by #1104
Labels

Comments

@sam-step
Copy link
Contributor

Hi, I recently ran into the "inferred type of this node exceeds the maximum length" error referenced in #449

TS7056: The inferred type of this node exceeds the maximum length the compiler will serialize. An explicit type annotation is needed.

It can be easily reproduced by running ts-proto on google/protobuf/descriptor.proto.

I understand turning off exact types works around the problem. However, the exact types feature is pretty nice so I'm looking to see if there are alternative solutions.

Following the compiler's suggestion to add explicit annotations seems to address the problem. E.g.

-export const FileDescriptorProto = {
+export const FileDescriptorProto: {
+    encode(message: FileDescriptorProto, writer?: _m0.Writer): _m0.Writer;
+    decode(input: (_m0.Reader | Uint8Array), length?: number): FileDescriptorProto;
+    fromJSON(object: any): FileDescriptorProto;
+    toJSON(message: FileDescriptorProto): unknown;
+    create<I extends Exact<DeepPartial<FileDescriptorProto>, I>>(base?: I): FileDescriptorProto;
+    fromPartial<I extends Exact<DeepPartial<FileDescriptorProto>, I>>(object: I): FileDescriptorProto;
+} = {
  encode(message: FileDescriptorProto, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {

Is adding support for these explicit annotations something you'd consider (it certainly produces more verbose code...)? If so, happy to take a stab at contributing a PR for it.

@stephenh
Copy link
Owner

stephenh commented Aug 6, 2024

Hi @sam-step ! Apologies for the late reply, but the explicit annotations sounds worth trying!

I was thinking it will blow up the *.ts file output size, but it's just a type, so would get minimized out in terms of actual *.js code.

Do you think doing an interface like const FileDescriptProto: SomeGenericType<FileDescriptorProto> would also work?

Even if SomeGenericType itself was included in the generated output (instead of being imported from an npm library), seems like that would also cut down on the output size quite a bit.

If you're still interested, would be great to have a PR! Thanks!

@sam-step
Copy link
Contributor Author

sam-step commented Sep 1, 2024

Do you think doing an interface like const FileDescriptProto: SomeGenericType would also work?

Yes, I like this idea a lot! Addresses the compilation issue and has the nice side benefit of a much smaller (and more readable) declarations file.

Took a closer look at the code and I think this should be doable. A few questions came up that I want to get feedback on before investing more time here:

Naming
These static object have the same name as their respective messages, so initially Message sounded right. But it feels misleading. It's not really a message, but rather a companion object of static methods, mostly encoding & decoding utilities. StaticMessage? MessageUtilities?

Flag or default?
I'd imaging making this behavior default and avoiding yet another flag (and series of conditionals in the code) would be desired. Assuming it doesn't actually change the semantics of the generated code. But lmk if you think it'd be better to put it behind a flag.

Constant members
With outputTypeAnnotations and outputTypeRegistry flags, the static object has a const $type property. Defining it as a string in the interface would result in reduced type specificity, so I think if either of these flags are set, we'd have to further parameterize the interface, like:

interface StaticMessage<T, N extends string> {
  readonly $type: N
  encode(message: T, writer?: BinaryWriter): BinaryWriter
  decode(input: BinaryReader | Uint8Array, length?: number): T
  // Others...
}

export const FileDescriptorProto: StaticMessage<FileDescriptorProto, 'google.protobuf.FileDescriptorProto'> = {
  $type: "google.protobuf.FileDescriptorProto" as const,
  // ...
}

const type = FileDescriptorProto.$type // Guaranteed to be "google.protobuf.FileDescriptorProto"

Does that make sense? I think this is the only way to retain the same level of specificity here, but I might be missing something.

Conditional methods
If outputExtensions is set, the setExtension and getExtension methods are conditionally added to the static object depending on whether the target message has any extensions defined or not. So a single interface per build/file won't necessarily work.

Assuming these are the only two conditionally added methods, one option could be just create a second interface and union them when needed. E.g.:

interface StaticMessage<T> {
  encode(message: T, writer?: BinaryWriter): BinaryWriter
  decode(input: BinaryReader | Uint8Array, length?: number): T
  // Others...
}

interface Extendable<T> {
  getExtension<V>(message: T, extension: Extension<V>): V | undefined
  setExtension<V>(message: T, extension: Extension<V>, value: V): void
}

const MyMessage: StaticMessage<MyMessage> & Extendable<MyMessage> = {
  // ...
}

const MyOtherMessageWithoutExtensions: StaticMessage<MyOtherMessageWithoutExtensions> = {
  // ...
}

Another route could be to just not conditionally add methods to the object (ie always add get/setExtension methods if outputExtensions is true, regardless of whether the message has any extensions). But it seems wasteful to add methods that'll never get called.

@stephenh
Copy link
Owner

stephenh commented Sep 2, 2024

Naming

Good question, maybe MessageProto or MessageConst or MessageMethods or MessageFns. Maybe MessageFns?

Flag or default

I'm good with default, since it's "just types" and should get stripped out of any bundled code and not affect output size.

constant members

Good catch on the $type and yep makes sense it can be a generic--should work.

conditional methods

Another great catch--the StaticMessage<MyMessage> & Extendable<MyMessage> seems pretty nice to me, if that works out.

Thanks for all of the research into this so far! You've found several things I would not thought of until getting ~1/2th way through the PR. :-)

stephenh pushed a commit that referenced this issue Sep 6, 2024
Fixes #1080

Adds an interface for the static message methods to avoid tsc errors on
large messages.

Since the set of static methods created may vary from
message-to-message, multiple interfaces were created and are unioned as
needed:
- `MessageFns`. The common encode/decode methods. All the message
objects implement this.
- `ExtensionFns`. The get/set extension methods. Added if extensions are
enabled and the message has extensions.
- `ExtensionHolder`. The individual extension properties.
- `[Struct|AnyValue|ListValue|FieldMask]WrapperFns`. The wrap/unwrap
methods for the well-known types.
@stephenh
Copy link
Owner

stephenh commented Sep 6, 2024

🎉 This issue has been resolved in version 2.2.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

@novascreen
Copy link

@stephenh can we expect this version to show up on the buf registry some time soon? the latest there is still at v1.178.0 🙏

@stephenh
Copy link
Owner

Hi @novascreen ! ts-proto used to push its own artifacts to the Buf registry, but as of ~ a year ago, I believe Buf controls all publishing of the community plugins like ts-proto.

@timosaikkonen does that sound right? Is there anything you can poke at, to get the latest versions pulling back into the Buf registry?

Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants