diff --git a/README.markdown b/README.markdown index 7e811efe7..1b7e8bfc2 100644 --- a/README.markdown +++ b/README.markdown @@ -547,6 +547,58 @@ Generated code will be placed in the Gradle build directory. - With `--ts_proto_opt=comments=false`, comments won't be copied from the proto files to the generated code. +- With `--ts_proto_opt=useNullAsOptional=true`, `undefined` values will be converted to `null`, and if you use `optional` label in your `.proto` file, the field will have `undefined` type as well. for example: + +```protobuf +message ProfileInfo { + int32 id = 1; + string bio = 2; + string phone = 3; +} + +message Department { + int32 id = 1; + string name = 2; +} + +message User { + int32 id = 1; + string username = 2; + /* + ProfileInfo will be optional in typescript, the type will be ProfileInfo | null | undefined + this is needed in cases where you don't wanna provide any value for the profile. + */ + optional ProfileInfo profile = 3; + + /* + Department only accepts a Department type or null, so this means you have to pass it null if there is no value available. + */ + Department department = 4; +} +``` + +the generated interfaces will be: + +```typescript +export interface ProfileInfo { + id: number; + bio: string; + phone: string; +} + +export interface Department { + id: number; + name: string; +} + +export interface User { + id: number; + username: string; + profile?: ProfileInfo | null | undefined; // check this one + department: Department | null; // check this one +} +``` + ### NestJS Support We have a great way of working together with [nestjs](https://docs.nestjs.com/microservices/grpc). `ts-proto` generates `interfaces` and `decorators` for you controller, client. For more information see the [nestjs readme](NESTJS.markdown). diff --git a/integration/use-null-as-optional/parameters.txt b/integration/use-null-as-optional/parameters.txt new file mode 100644 index 000000000..9fde71303 --- /dev/null +++ b/integration/use-null-as-optional/parameters.txt @@ -0,0 +1 @@ +useNullAsOptional=true \ No newline at end of file diff --git a/integration/use-null-as-optional/use-null-as-optional.bin b/integration/use-null-as-optional/use-null-as-optional.bin new file mode 100644 index 000000000..c3036fb4b Binary files /dev/null and b/integration/use-null-as-optional/use-null-as-optional.bin differ diff --git a/integration/use-null-as-optional/use-null-as-optional.proto b/integration/use-null-as-optional/use-null-as-optional.proto new file mode 100644 index 000000000..07b8eaa16 --- /dev/null +++ b/integration/use-null-as-optional/use-null-as-optional.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +package useNullAsOptional; + +message ProfileInfo { + int32 id = 1; + string bio = 2; + string phone = 3; +} + +message User { + int32 id = 1; + string username = 2; + optional ProfileInfo profile = 3; +} + +message UserById { + int32 id = 1; +} + +service HeroService { + rpc FindOneHero (UserById) returns (User) {} +} diff --git a/integration/use-null-as-optional/use-null-as-optional.ts b/integration/use-null-as-optional/use-null-as-optional.ts new file mode 100644 index 000000000..999350987 --- /dev/null +++ b/integration/use-null-as-optional/use-null-as-optional.ts @@ -0,0 +1,297 @@ +/* eslint-disable */ +import * as _m0 from "protobufjs/minimal"; + +export const protobufPackage = "useNullAsOptional"; + +export interface ProfileInfo { + id: number; + bio: string; + phone: string; +} + +export interface User { + id: number; + username: string; + profile?: ProfileInfo | null | undefined; +} + +export interface UserById { + id: number; +} + +function createBaseProfileInfo(): ProfileInfo { + return { id: 0, bio: "", phone: "" }; +} + +export const ProfileInfo = { + encode(message: ProfileInfo, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.id !== 0) { + writer.uint32(8).int32(message.id); + } + if (message.bio !== "") { + writer.uint32(18).string(message.bio); + } + if (message.phone !== "") { + writer.uint32(26).string(message.phone); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): ProfileInfo { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseProfileInfo(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 8) { + break; + } + + message.id = reader.int32(); + continue; + case 2: + if (tag !== 18) { + break; + } + + message.bio = reader.string(); + continue; + case 3: + if (tag !== 26) { + break; + } + + message.phone = reader.string(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): ProfileInfo { + return { + id: isSet(object.id) ? globalThis.Number(object.id) : 0, + bio: isSet(object.bio) ? globalThis.String(object.bio) : "", + phone: isSet(object.phone) ? globalThis.String(object.phone) : "", + }; + }, + + toJSON(message: ProfileInfo): unknown { + const obj: any = {}; + if (message.id !== 0) { + obj.id = Math.round(message.id); + } + if (message.bio !== "") { + obj.bio = message.bio; + } + if (message.phone !== "") { + obj.phone = message.phone; + } + return obj; + }, + + create, I>>(base?: I): ProfileInfo { + return ProfileInfo.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): ProfileInfo { + const message = createBaseProfileInfo(); + message.id = object.id ?? 0; + message.bio = object.bio ?? ""; + message.phone = object.phone ?? ""; + return message; + }, +}; + +function createBaseUser(): User { + return { id: 0, username: "", profile: null }; +} + +export const User = { + encode(message: User, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.id !== 0) { + writer.uint32(8).int32(message.id); + } + if (message.username !== "") { + writer.uint32(18).string(message.username); + } + if (message.profile !== undefined && message.profile !== null) { + ProfileInfo.encode(message.profile, writer.uint32(26).fork()).ldelim(); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): User { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseUser(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 8) { + break; + } + + message.id = reader.int32(); + continue; + case 2: + if (tag !== 18) { + break; + } + + message.username = reader.string(); + continue; + case 3: + if (tag !== 26) { + break; + } + + message.profile = ProfileInfo.decode(reader, reader.uint32()); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): User { + return { + id: isSet(object.id) ? globalThis.Number(object.id) : 0, + username: isSet(object.username) ? globalThis.String(object.username) : "", + profile: isSet(object.profile) ? ProfileInfo.fromJSON(object.profile) : null, + }; + }, + + toJSON(message: User): unknown { + const obj: any = {}; + if (message.id !== 0) { + obj.id = Math.round(message.id); + } + if (message.username !== "") { + obj.username = message.username; + } + if (message.profile !== undefined && message.profile !== null) { + obj.profile = ProfileInfo.toJSON(message.profile); + } + return obj; + }, + + create, I>>(base?: I): User { + return User.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): User { + const message = createBaseUser(); + message.id = object.id ?? 0; + message.username = object.username ?? ""; + message.profile = (object.profile !== undefined && object.profile !== null) + ? ProfileInfo.fromPartial(object.profile) + : undefined; + return message; + }, +}; + +function createBaseUserById(): UserById { + return { id: 0 }; +} + +export const UserById = { + encode(message: UserById, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.id !== 0) { + writer.uint32(8).int32(message.id); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): UserById { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseUserById(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 8) { + break; + } + + message.id = reader.int32(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): UserById { + return { id: isSet(object.id) ? globalThis.Number(object.id) : 0 }; + }, + + toJSON(message: UserById): unknown { + const obj: any = {}; + if (message.id !== 0) { + obj.id = Math.round(message.id); + } + return obj; + }, + + create, I>>(base?: I): UserById { + return UserById.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): UserById { + const message = createBaseUserById(); + message.id = object.id ?? 0; + return message; + }, +}; + +export interface HeroService { + FindOneHero(request: UserById): Promise; +} + +export const HeroServiceServiceName = "useNullAsOptional.HeroService"; +export class HeroServiceClientImpl implements HeroService { + private readonly rpc: Rpc; + private readonly service: string; + constructor(rpc: Rpc, opts?: { service?: string }) { + this.service = opts?.service || HeroServiceServiceName; + this.rpc = rpc; + this.FindOneHero = this.FindOneHero.bind(this); + } + FindOneHero(request: UserById): Promise { + const data = UserById.encode(request).finish(); + const promise = this.rpc.request(this.service, "FindOneHero", data); + return promise.then((data) => User.decode(_m0.Reader.create(data))); + } +} + +interface Rpc { + request(service: string, method: string, data: Uint8Array): Promise; +} + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +export type DeepPartial = T extends Builtin ? T + : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +export type Exact = P extends Builtin ? P + : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; + +function isSet(value: any): boolean { + return value !== null && value !== undefined; +} diff --git a/integration/use-null-nestjs-simple/google/protobuf/empty.ts b/integration/use-null-nestjs-simple/google/protobuf/empty.ts new file mode 100644 index 000000000..642a965dc --- /dev/null +++ b/integration/use-null-nestjs-simple/google/protobuf/empty.ts @@ -0,0 +1,19 @@ +/* eslint-disable */ + +export const protobufPackage = "google.protobuf"; + +/** + * A generic empty message that you can re-use to avoid defining duplicated + * empty messages in your APIs. A typical example is to use it as the request + * or the response type of an API method. For instance: + * + * service Foo { + * rpc Bar(google.protobuf.Empty) returns (google.protobuf.Empty); + * } + * + * The JSON representation for `Empty` is empty JSON object `{}`. + */ +export interface Empty { +} + +export const GOOGLE_PROTOBUF_PACKAGE_NAME = "google.protobuf"; diff --git a/integration/use-null-nestjs-simple/parameters.txt b/integration/use-null-nestjs-simple/parameters.txt new file mode 100644 index 000000000..68f2cc820 --- /dev/null +++ b/integration/use-null-nestjs-simple/parameters.txt @@ -0,0 +1 @@ +nestJs=true,globalThisPolyfill=true,useNullAsOptional=true diff --git a/integration/use-null-nestjs-simple/user.bin b/integration/use-null-nestjs-simple/user.bin new file mode 100644 index 000000000..bd75d8430 Binary files /dev/null and b/integration/use-null-nestjs-simple/user.bin differ diff --git a/integration/use-null-nestjs-simple/user.proto b/integration/use-null-nestjs-simple/user.proto new file mode 100644 index 000000000..c53da53d3 --- /dev/null +++ b/integration/use-null-nestjs-simple/user.proto @@ -0,0 +1,54 @@ +syntax = "proto3"; + +import "google/protobuf/empty.proto"; + +package user; + + +/** + Most ORMs ignore the `undefined` value, either we have the value from DB or it's null. + with this flag you can have better alignment with your ORM ( drizzle, TypeOrm, PrismaJS, etc) Entities. + Or when you wanna share the grpc interfaces with NextJS `getStaticProps` and `getServerSideProps` in which they won't accept `undefined` ( it will cause runtime error ) + please check https://sdorra.dev/posts/2023-03-20-typescript-undefined-to-null for more info + let's say on querying the database with relations, there might be a value for the relation or not (it's null). + with this flag the generated interfaces will be aligned with it. + now let's say you have a grpc method which might return user's profile as well or not ( depending on querying the relation or not for example ) + with `optional` modifier you can replicate this as well. the optional field will have `ProfileInfo | null | undefined` +**/ + +service UserService { + rpc AddOneUser (User) returns (google.protobuf.Empty) {} + rpc FindOneUser (UserById) returns (User) {} + rpc FindManyUser (stream UserById) returns (stream User) {} +} + +message ProfileInfo { + int32 id = 1; + string bio = 2; + string phone = 3; +} + +message Department { + int32 id = 1; + string name = 2; +} + +message User { + int32 id = 1; + string username = 2; + /* + ProfileInfo will be optional in typescript, the type will be ProfileInfo | null | undefined + this is needed in cases where you don't wanna provide any value for the profile. + */ + optional ProfileInfo profile = 3; + + /* + Department only accepts a Department type or null, so this means you have to pass it null if there is no value available. + */ + Department department = 4; +} + +message UserById { + int32 id = 1; +} + diff --git a/integration/use-null-nestjs-simple/user.ts b/integration/use-null-nestjs-simple/user.ts new file mode 100644 index 000000000..5bd1ca30b --- /dev/null +++ b/integration/use-null-nestjs-simple/user.ts @@ -0,0 +1,71 @@ +/* eslint-disable */ +import { GrpcMethod, GrpcStreamMethod } from "@nestjs/microservices"; +import { Observable } from "rxjs"; +import { Empty } from "./google/protobuf/empty"; + +export const protobufPackage = "user"; + +export interface ProfileInfo { + id: number; + bio: string; + phone: string; +} + +export interface Department { + id: number; + name: string; +} + +export interface User { + id: number; + username: string; + /** + * ProfileInfo will be optional in typescript, the type will be ProfileInfo | null | undefined + * this is needed in cases where you don't wanna provide any value for the profile. + */ + profile?: + | ProfileInfo + | null + | undefined; + /** Department only accepts a Department type or null, so this means you have to pass it null if there is no value available. */ + department: Department | null; +} + +export interface UserById { + id: number; +} + +export const USER_PACKAGE_NAME = "user"; + +export interface UserServiceClient { + addOneUser(request: User): Observable; + + findOneUser(request: UserById): Observable; + + findManyUser(request: Observable): Observable; +} + +export interface UserServiceController { + addOneUser(request: User): void; + + findOneUser(request: UserById): Promise | Observable | User; + + findManyUser(request: Observable): Observable; +} + +export function UserServiceControllerMethods() { + return function (constructor: Function) { + const grpcMethods: string[] = ["addOneUser", "findOneUser"]; + for (const method of grpcMethods) { + const descriptor: any = Reflect.getOwnPropertyDescriptor(constructor.prototype, method); + GrpcMethod("UserService", method)(constructor.prototype[method], method, descriptor); + } + const grpcStreamMethods: string[] = ["findManyUser"]; + for (const method of grpcStreamMethods) { + const descriptor: any = Reflect.getOwnPropertyDescriptor(constructor.prototype, method); + GrpcStreamMethod("UserService", method)(constructor.prototype[method], method, descriptor); + } + }; +} + +export const USER_SERVICE_NAME = "UserService"; diff --git a/src/main.ts b/src/main.ts index a2ad6aa87..97d7ecc93 100644 --- a/src/main.ts +++ b/src/main.ts @@ -96,8 +96,13 @@ import { impFile, impProto, maybeAddComment, + nullOrUndefined, maybePrefixPackage, safeAccessor, + withOrMaybeCheckIsNull, + withAndMaybeCheckIsNotNull, + withOrMaybeCheckIsNotNull, + withAndMaybeCheckIsNull, } from "./utils"; import { visit, visitServices } from "./visit"; @@ -1007,7 +1012,7 @@ function generateOneofProperty( ); const name = maybeSnakeToCamel(messageDesc.oneofDecl[oneofIndex].name, options); - return code`${mbReadonly}${name}?: ${unionType} | undefined,`; + return code`${mbReadonly}${name}?: ${unionType} | ${nullOrUndefined(options)},`; /* // Ideally we'd put the comments for each oneof field next to the anonymous @@ -1053,7 +1058,7 @@ function generateBaseInstanceFactory( const name = options.useJsonName ? getFieldName(field, options) : maybeSnakeToCamel(messageDesc.oneofDecl[oneofIndex].name, ctx.options); - fields.push(code`${safeAccessor(name)}: undefined`); + fields.push(code`${safeAccessor(name)}: ${nullOrUndefined(options)}`); } continue; } @@ -1067,7 +1072,7 @@ function generateBaseInstanceFactory( const fieldKey = safeAccessor(getFieldName(field, options)); const val = isWithinOneOf(field) - ? "undefined" + ? nullOrUndefined(options) : isMapType(ctx, messageDesc, field) ? shouldGenerateJSMapType(ctx, messageDesc, field) ? "new Map()" @@ -1228,14 +1233,14 @@ function generateDecode(ctx: Context, fullName: string, messageDesc: DescriptorP } const initializerSnippet = initializerNecessary ? ` - if (${messageProperty} === undefined) { + if (${messageProperty} === undefined ${withOrMaybeCheckIsNull(options, messageProperty)}) { ${messageProperty} = ${generateMapType ? "new Map()" : "{}"}; }` : ""; chunks.push(code` ${tagCheck} const ${varName} = ${readSnippet}; - if (${varName}.value !== undefined) { + if (${varName}.value !== undefined ${withAndMaybeCheckIsNotNull(options, `${varName}.value`)}) { ${initializerSnippet} ${valueSetterSnippet}; } @@ -1243,7 +1248,7 @@ function generateDecode(ctx: Context, fullName: string, messageDesc: DescriptorP } else { const initializerSnippet = initializerNecessary ? ` - if (${messageProperty} === undefined) { + if (${messageProperty} === undefined ${withOrMaybeCheckIsNull(options, messageProperty)}) { ${messageProperty} = []; }` : ""; @@ -1311,7 +1316,7 @@ function generateDecode(ctx: Context, fullName: string, messageDesc: DescriptorP if (!options.initializeFieldsAsUndefined) { unknownFieldsInitializerSnippet = ` - if (message._unknownFields === undefined) { + if (message._unknownFields === undefined ${withOrMaybeCheckIsNull(options, `message._unknownFields`)}) { message._unknownFields = {}; } `; @@ -1325,7 +1330,7 @@ function generateDecode(ctx: Context, fullName: string, messageDesc: DescriptorP ${unknownFieldsInitializerSnippet} const list = message._unknownFields${maybeNonNullAssertion}[tag]; - if (list === undefined) { + if (list === undefined ${withOrMaybeCheckIsNull(options, `message._unknownFields`)}) { message._unknownFields${maybeNonNullAssertion}[tag] = [buf]; } else { list.push(buf); @@ -1455,7 +1460,7 @@ function generateEncode(ctx: Context, fullName: string, messageDesc: DescriptorP const maybeTypeField = addTypeToMessages(options) ? `$type: '${field.typeName.slice(1)}',` : ""; const entryWriteSnippet = isValueType(ctx, valueType) ? code` - if (value !== undefined) { + if (value !== undefined ${withOrMaybeCheckIsNotNull(options, `value`)}) { ${writeSnippet(`{ ${maybeTypeField} key: key as any, value }`)}; } ` @@ -1566,7 +1571,10 @@ function generateEncode(ctx: Context, fullName: string, messageDesc: DescriptorP } if (isOptional) { chunks.push(code` - if (${messageProperty} !== undefined && ${messageProperty}.length !== 0) { + if (${messageProperty} !== undefined ${withAndMaybeCheckIsNotNull( + options, + messageProperty, + )} && ${messageProperty}.length !== 0) { ${listWriteSnippet} } `); @@ -1594,13 +1602,13 @@ function generateEncode(ctx: Context, fullName: string, messageDesc: DescriptorP } else if (isWithinOneOf(field)) { // Oneofs don't have a default value check b/c they need to denote which-oneof presence chunks.push(code` - if (${messageProperty} !== undefined) { + if (${messageProperty} !== undefined ${withAndMaybeCheckIsNotNull(options, messageProperty)}) { ${writeSnippet(`${messageProperty}`)}; } `); } else if (isMessage(field)) { chunks.push(code` - if (${messageProperty} !== undefined) { + if (${messageProperty} !== undefined ${withAndMaybeCheckIsNotNull(options, messageProperty)}) { ${writeSnippet(`${messageProperty}`)}; } `); @@ -2035,7 +2043,7 @@ function generateFromJson(ctx: Context, fullName: string, fullTypeName: string, const i = convertFromObjectKey(ctx, messageDesc, field, "key"); if (shouldGenerateJSMapType(ctx, messageDesc, field)) { - const fallback = noDefaultValue ? "undefined" : "new Map()"; + const fallback = noDefaultValue ? nullOrUndefined(options) : "new Map()"; chunks.push(code` ${fieldKey}: ${ctx.utils.isObject}(${jsonProperty}) @@ -2046,7 +2054,7 @@ function generateFromJson(ctx: Context, fullName: string, fullTypeName: string, : ${fallback}, `); } else { - const fallback = noDefaultValue ? "undefined" : "{}"; + const fallback = noDefaultValue ? nullOrUndefined(options) : "{}"; chunks.push(code` ${fieldKey}: ${ctx.utils.isObject}(${jsonProperty}) @@ -2058,7 +2066,7 @@ function generateFromJson(ctx: Context, fullName: string, fullTypeName: string, `); } } else { - const fallback = noDefaultValue ? "undefined" : "[]"; + const fallback = noDefaultValue ? nullOrUndefined(options) : "[]"; const readValueSnippet = readSnippet("e"); if (readValueSnippet.toString() === code`e`.toString()) { @@ -2087,27 +2095,27 @@ function generateFromJson(ctx: Context, fullName: string, fullTypeName: string, chunks.push(code`${ternaryIf} ? ${ternaryThen}} : `); if (field === lastCase) { - chunks.push(code`undefined,`); + chunks.push(code`${nullOrUndefined(options)},`); } } else if (isAnyValueType(field)) { chunks.push(code`${fieldKey}: ${ctx.utils.isSet}(${jsonPropertyOptional}) ? ${readSnippet(`${jsonProperty}`)} - : undefined, + : ${nullOrUndefined(options)}, `); } else if (isStructType(field)) { chunks.push( code`${fieldKey}: ${ctx.utils.isObject}(${jsonProperty}) ? ${readSnippet(`${jsonProperty}`)} - : undefined,`, + : ${nullOrUndefined(options)},`, ); } else if (isListValueType(field)) { chunks.push(code` ${fieldKey}: ${ctx.utils.globalThis}.Array.isArray(${jsonProperty}) ? ${readSnippet(`${jsonProperty}`)} - : undefined, + : ${nullOrUndefined(options)}, `); } else { - const fallback = isWithinOneOf(field) || noDefaultValue ? "undefined" : defaultValue(ctx, field); + const fallback = isWithinOneOf(field) || noDefaultValue ? nullOrUndefined(options) : defaultValue(ctx, field); chunks.push(code` ${fieldKey}: ${ctx.utils.isSet}(${jsonProperty}) ? ${readSnippet(`${jsonProperty}`)} @@ -2124,10 +2132,10 @@ function generateFromJson(ctx: Context, fullName: string, fullTypeName: string, function generateCanonicalToJson( fullName: string, fullProtobufTypeName: string, - { useOptionals }: Options, + { useOptionals, useNullAsOptional }: Options, ): Code | undefined { if (isFieldMaskTypeName(fullProtobufTypeName)) { - const returnType = useOptionals === "all" ? "string | undefined" : "string"; + const returnType = useOptionals === "all" ? `string | ${nullOrUndefined({ useNullAsOptional })}` : "string"; const pathModifier = useOptionals === "all" ? "?" : ""; return code` @@ -2289,7 +2297,7 @@ function generateToJson( const check = (isScalar(field) || isEnum(field)) && !(isWithinOneOf(field) || emitDefaultValuesForJson) ? notDefaultCheck(ctx, field, messageDesc.options, `${messageProperty}`) - : `${messageProperty} !== undefined`; + : `${messageProperty} !== undefined ${withAndMaybeCheckIsNotNull(options, messageProperty)}`; chunks.push(code` if (${check}) { diff --git a/src/options.ts b/src/options.ts index 91d985848..b50191cef 100644 --- a/src/options.ts +++ b/src/options.ts @@ -98,6 +98,7 @@ export type Options = { comments: boolean; disableProto2Optionals: boolean; disableProto2DefaultValues: boolean; + useNullAsOptional: boolean; }; export function defaultOptions(): Options { @@ -161,6 +162,7 @@ export function defaultOptions(): Options { comments: true, disableProto2Optionals: false, disableProto2DefaultValues: false, + useNullAsOptional: false, }; } diff --git a/src/types.ts b/src/types.ts index 41d56f8dc..3d986a33c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,7 +13,14 @@ import { import { code, Code, imp, Import } from "ts-poet"; import { DateOption, EnvOption, LongOption, OneofOption, Options } from "./options"; import { visit } from "./visit"; -import { fail, FormattedMethodDescriptor, impProto, maybePrefixPackage } from "./utils"; +import { + fail, + FormattedMethodDescriptor, + impProto, + nullOrUndefined, + maybePrefixPackage, + withAndMaybeCheckIsNotNull, +} from "./utils"; import SourceInfo from "./sourceInfo"; import { uncapitalize } from "./case"; import { BaseContext, Context } from "./context"; @@ -247,7 +254,7 @@ export function defaultValue(ctx: Context, field: FieldDescriptorProto): any { case FieldDescriptorProto_Type.TYPE_MESSAGE: case FieldDescriptorProto_Type.TYPE_GROUP: default: - return "undefined"; + return nullOrUndefined(options); } } @@ -260,7 +267,9 @@ export function notDefaultCheck( ): Code { const { typeMap, options, currentFile } = ctx; const isOptional = isOptionalProperty(field, messageOptions, options, currentFile.isProto3Syntax); - const maybeNotUndefinedAnd = isOptional ? `${place} !== undefined && ` : ""; + const maybeNotUndefinedAnd = isOptional + ? `${place} !== undefined ${withAndMaybeCheckIsNotNull(options, place)} &&` + : ""; switch (field.type) { case FieldDescriptorProto_Type.TYPE_DOUBLE: case FieldDescriptorProto_Type.TYPE_FLOAT: @@ -587,7 +596,7 @@ export function messageToTypeName( if (typeOptions.repeated ?? false) { return valueType; } - return code`${valueType} | undefined`; + return code`${valueType} | ${nullOrUndefined(options)}`; } // Look for other special prototypes like Timestamp that aren't technically wrapper types if (!typeOptions.keepValueType && protoType === ".google.protobuf.Timestamp") { @@ -627,7 +636,7 @@ export function toTypeName( ): Code { function finalize(type: Code, isOptional: boolean) { if (isOptional) { - return code`${type} | undefined`; + return code`${type} | ${nullOrUndefined(ctx.options, field.proto3Optional)}`; } return type; } diff --git a/src/utils.ts b/src/utils.ts index 9983c8f9e..d938d347c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -303,3 +303,26 @@ export function arrowFunction(params: string, body: Code | string, isOneLine: bo } return code`(${params}) => { ${body} }`; } + +export function nullOrUndefined(options: Pick, hasProto3Optional: boolean = false) { + return options.useNullAsOptional ? `null ${hasProto3Optional ? "| undefined" : ""}` : "undefined"; +} +export function maybeCheckIsNotNull(options: Pick, typeName: string, prefix?: string) { + return options.useNullAsOptional ? ` ${prefix} ${typeName} !== null` : ""; +} +export function maybeCheckIsNull(options: Pick, typeName: string, prefix?: string) { + return options.useNullAsOptional ? ` ${prefix} ${typeName} === null` : ""; +} + +export function withOrMaybeCheckIsNotNull(options: Pick, typeName: string) { + return maybeCheckIsNotNull(options, typeName, "||"); +} +export function withOrMaybeCheckIsNull(options: Pick, typeName: string) { + return maybeCheckIsNull(options, typeName, "||"); +} +export function withAndMaybeCheckIsNotNull(options: Pick, typeName: string) { + return maybeCheckIsNotNull(options, typeName, "&&"); +} +export function withAndMaybeCheckIsNull(options: Pick, typeName: string) { + return maybeCheckIsNotNull(options, typeName, "&&"); +} diff --git a/tests/options-test.ts b/tests/options-test.ts index 11fb1e066..e6e7b2118 100644 --- a/tests/options-test.ts +++ b/tests/options-test.ts @@ -61,6 +61,7 @@ describe("options", () => { "useJsonWireFormat": false, "useMapType": false, "useMongoObjectId": false, + "useNullAsOptional": false, "useNumericEnumForJson": false, "useOptionals": "none", "usePrototypeForDefaults": false, @@ -195,4 +196,12 @@ describe("options", () => { outputServices: [ServiceOption.DEFAULT, ServiceOption.GENERIC], }); }); + + it("allow use 'null' instead of 'undefined'", () => { + const options = optionsFromParameter("useNullAsOptional=true"); + expect(options).toMatchObject({ + useNullAsOptional: true, + // outputServices: [ServiceOption.DEFAULT, ServiceOption.GENERIC], + }); + }); }); diff --git a/tests/types-test.ts b/tests/types-test.ts index a373762e5..7223ad48a 100644 --- a/tests/types-test.ts +++ b/tests/types-test.ts @@ -54,6 +54,13 @@ describe("types", () => { options: { ...defaultOptions(), useOptionals: "all" }, expected: code`string | undefined`, }, + { + descr: 'use "null" value instead of "undefined" (useNullAsOptional=true)', + typeMap: new Map(), + protoType: ".google.protobuf.StringValue", + options: { ...defaultOptions(), useNullAsOptional: true }, + expected: code`string | null`, + }, ]; testCases.forEach((t) => it(t.descr, async () => {