Skip to content

Commit

Permalink
feat: allow $type to be optional (#1013)
Browse files Browse the repository at this point in the history
### Description

Add another possible value to the `outputTypeAnnotations` option:
`optional`. This makes type definitions on interfaces optional, which
may be useful if you want to use the `$type` field for runtime type
checking on responses from a server but don't want to have to set the
$type on each request message you create.

### Testing

Ensured this causes no regressions for existing integration tests. Also
added new integration tests to prevent future regressions.
  • Loading branch information
lukealvoeiro authored Mar 8, 2024
1 parent b9b0ff7 commit f285557
Show file tree
Hide file tree
Showing 7 changed files with 266 additions and 3 deletions.
2 changes: 1 addition & 1 deletion README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ Generated code will be placed in the Gradle build directory.

- With `--ts_proto_opt=outputSchema=true`, meta typings will be generated that can later be used in other code generators.

- With `--ts_proto_opt=outputTypeAnnotations=true`, each message will be given a `$type` field containing its fully-qualified name. You can use `--ts_proto_opt=outputTypeAnnotations=static-only` to omit it from the `interface` declaration.
- With `--ts_proto_opt=outputTypeAnnotations=true`, each message will be given a `$type` field containing its fully-qualified name. You can use `--ts_proto_opt=outputTypeAnnotations=static-only` to omit it from the `interface` declaration, or `--ts_proto_opt=outputTypeAnnotations=optional` to make it an optional property on the `interface` definition. The latter option may be useful if you want to use the `$type` field for runtime type checking on responses from a server.

- With `--ts_proto_opt=outputTypeRegistry=true`, the type registry will be generated that can be used to resolve message types by fully-qualified name. Also, each message will be given a `$type` field containing its fully-qualified name.

Expand Down
1 change: 1 addition & 0 deletions integration/optional-type-definitions/parameters.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
outputTypeAnnotations=optional
Binary file added integration/optional-type-definitions/simple.bin
Binary file not shown.
23 changes: 23 additions & 0 deletions integration/optional-type-definitions/simple.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Adding a comment to the syntax will become the first
// comment in the output source file.
syntax = "proto3";

package simple;

// This comment is separated by a blank non-comment line, and will detach from
// the following comment on the message Simple.

/** Example comment on the Simple message */
message Simple {
// Name field
string name = 1 [deprecated = true];
/** Age field */
int32 age = 2 [deprecated = true];
Child child = 3 [deprecated = true]; // This comment will also attach;
string test_field = 4 [deprecated = true];
string test_not_deprecated = 5 [deprecated = false];
}

message Child {
string name = 1;
}
239 changes: 239 additions & 0 deletions integration/optional-type-definitions/simple.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
/* eslint-disable */
import * as _m0 from "protobufjs/minimal";

export const protobufPackage = "simple";

/**
* Adding a comment to the syntax will become the first
* comment in the output source file.
*/

/** Example comment on the Simple message */
export interface Simple {
$type?: "simple.Simple";
/**
* Name field
*
* @deprecated
*/
name: string;
/**
* Age field
*
* @deprecated
*/
age: number;
/**
* This comment will also attach;
*
* @deprecated
*/
child:
| Child
| undefined;
/** @deprecated */
testField: string;
testNotDeprecated: string;
}

export interface Child {
$type?: "simple.Child";
name: string;
}

function createBaseSimple(): Simple {
return { $type: "simple.Simple", name: "", age: 0, child: undefined, testField: "", testNotDeprecated: "" };
}

export const Simple = {
$type: "simple.Simple" as const,

encode(message: Simple, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
if (message.name !== "") {
writer.uint32(10).string(message.name);
}
if (message.age !== 0) {
writer.uint32(16).int32(message.age);
}
if (message.child !== undefined) {
Child.encode(message.child, writer.uint32(26).fork()).ldelim();
}
if (message.testField !== "") {
writer.uint32(34).string(message.testField);
}
if (message.testNotDeprecated !== "") {
writer.uint32(42).string(message.testNotDeprecated);
}
return writer;
},

decode(input: _m0.Reader | Uint8Array, length?: number): Simple {
const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseSimple();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
if (tag !== 10) {
break;
}

message.name = reader.string();
continue;
case 2:
if (tag !== 16) {
break;
}

message.age = reader.int32();
continue;
case 3:
if (tag !== 26) {
break;
}

message.child = Child.decode(reader, reader.uint32());
continue;
case 4:
if (tag !== 34) {
break;
}

message.testField = reader.string();
continue;
case 5:
if (tag !== 42) {
break;
}

message.testNotDeprecated = reader.string();
continue;
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skipType(tag & 7);
}
return message;
},

fromJSON(object: any): Simple {
return {
$type: Simple.$type,
name: isSet(object.name) ? globalThis.String(object.name) : "",
age: isSet(object.age) ? globalThis.Number(object.age) : 0,
child: isSet(object.child) ? Child.fromJSON(object.child) : undefined,
testField: isSet(object.testField) ? globalThis.String(object.testField) : "",
testNotDeprecated: isSet(object.testNotDeprecated) ? globalThis.String(object.testNotDeprecated) : "",
};
},

toJSON(message: Simple): unknown {
const obj: any = {};
if (message.name !== "") {
obj.name = message.name;
}
if (message.age !== 0) {
obj.age = Math.round(message.age);
}
if (message.child !== undefined) {
obj.child = Child.toJSON(message.child);
}
if (message.testField !== "") {
obj.testField = message.testField;
}
if (message.testNotDeprecated !== "") {
obj.testNotDeprecated = message.testNotDeprecated;
}
return obj;
},

create<I extends Exact<DeepPartial<Simple>, I>>(base?: I): Simple {
return Simple.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<Simple>, I>>(object: I): Simple {
const message = createBaseSimple();
message.name = object.name ?? "";
message.age = object.age ?? 0;
message.child = (object.child !== undefined && object.child !== null) ? Child.fromPartial(object.child) : undefined;
message.testField = object.testField ?? "";
message.testNotDeprecated = object.testNotDeprecated ?? "";
return message;
},
};

function createBaseChild(): Child {
return { $type: "simple.Child", name: "" };
}

export const Child = {
$type: "simple.Child" as const,

encode(message: Child, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
if (message.name !== "") {
writer.uint32(10).string(message.name);
}
return writer;
},

decode(input: _m0.Reader | Uint8Array, length?: number): Child {
const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseChild();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
if (tag !== 10) {
break;
}

message.name = reader.string();
continue;
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skipType(tag & 7);
}
return message;
},

fromJSON(object: any): Child {
return { $type: Child.$type, name: isSet(object.name) ? globalThis.String(object.name) : "" };
},

toJSON(message: Child): unknown {
const obj: any = {};
if (message.name !== "") {
obj.name = message.name;
}
return obj;
},

create<I extends Exact<DeepPartial<Child>, I>>(base?: I): Child {
return Child.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<Child>, I>>(object: I): Child {
const message = createBaseChild();
message.name = object.name ?? "";
return message;
},
};

type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;

export type DeepPartial<T> = T extends Builtin ? T
: T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in Exclude<keyof T, "$type">]?: DeepPartial<T[K]> }
: Partial<T>;

type KeysOfUnion<T> = T extends T ? keyof T : never;
export type Exact<P, I extends P> = P extends Builtin ? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P> | "$type">]: never };

function isSet(value: any): boolean {
return value !== null && value !== undefined;
}
2 changes: 1 addition & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -956,7 +956,7 @@ function generateInterfaceDeclaration(
chunks.push(code`export interface ${def(fullName)} {`);

if (addTypeToMessages(options)) {
chunks.push(code`$type: '${fullTypeName}',`);
chunks.push(code`$type${options.outputTypeAnnotations === "optional" ? "?" : ""}: '${fullTypeName}',`);
}

// When oneof=unions, we generate a single property with an ADT per `oneof` clause.
Expand Down
2 changes: 1 addition & 1 deletion src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export type Options = {
outputEncodeMethods: true | false | "encode-only" | "decode-only" | "encode-no-creation";
outputJsonMethods: true | false | "to-only" | "from-only";
outputPartialMethods: boolean;
outputTypeAnnotations: boolean | "static-only";
outputTypeAnnotations: boolean | "static-only" | "optional";
outputTypeRegistry: boolean;
stringEnums: boolean;
constEnums: boolean;
Expand Down

0 comments on commit f285557

Please sign in to comment.