-
Notifications
You must be signed in to change notification settings - Fork 349
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
Immitate protobuf.js for oneof and non-scalar behavior re: field optionality #74
Comments
Yeah, understood that is a preference. For the projects I built ts-proto for, we purposefully wanted that rigor. We also had generally-not-huge messages. I'd be cool with a config flag to turn on optionals. Happy to give some pointers if you need any, otherwise a PR would be great. (My main ask would be a new integration-tests example that enables your new flag and then shows what all of the generated output now looks like, maybe has a super-simple test case that shows it works, etc.) Thanks! |
The drawback to making all fields optional in typescript is that it breaks strictNullChecks. This would not compile: function getName( person:Person ): string {
return person.name;
} To create a Person with default values, you can use the fromPartial method: Person.fromPartial({
name: "Bob"
}); |
Ah shoot, that is a great point @timostamm . I'd forgotten about both the "now things are optional on read" aspect as well as @philikon does ^ work for you? I can add an FAQ entry to the home page around "btw, if you have a whole lot of message field, you should look at |
I don't quite follow. Can you elaborate with an example? I think you guys have clarified an important distinction though. But message SimpleWithWrappers {
google.protobuf.StringValue name = 1;
google.protobuf.Int32Value age = 2;
google.protobuf.BoolValue enabled = 3;
repeated google.protobuf.Int32Value coins = 6;
repeated google.protobuf.StringValue snacks = 7;
} translates to this export interface SimpleWithWrappers {
name?: string | null;
age?: number | null;
enabled?: boolean | null;
coins: number[];
snacks: string[];
}
const baseSimpleWithWrappers: object = {
};
// and the usual encode/decode functions I'd be very happy with that as I'd still get type checking on all the properties that are supposed to be there, but I can either not specify at all or pass |
Hmm, should
really be
perhaps? Not that it's actually possible to encode a "hole" in a For instance, if I do this using ts-proto's default options:
I get |
Let's assume you have this message interface: interface IPerson {
name?: (string|null);
age?: (number|null);
passport?: (Passport|null);
dl?: (DriversLicense|null);
govtId?: (GovtIssuedId|null);
} Creating objects with this interface is easy: let IPerson = {
name: 'foo'
}; But in most real world use cases, you are not only creating objects, you are reading from them. function showPersonalMessage(name: string, message: string) And you want to pass the name to it: let x:IPerson = {
age: 23
};
showPersonalMessage(x.name, 'hello') Do you see the Problem? You are passing a So what you should do is code like that: let name = x.name === null || x.name === undefined ? "" : x.name;
showPersonalMessage(name, 'hello'); This tends to get very verbose very quickly. And protobuf does not have this behaviour in other languages. In C# for example, this will never fail: var p = new Person {
Age = 23
};
void ShowPersonalMessage(name: string, message:string);
ShowPersonalMessage(p.Name); Same for PHP and C++, because they all have an empty string as default value. I have some background on this specific matter. We have an application where hundreds of protobuf message are used in Typescript (Angular), PHP, C# and C++. Angular receives the data as JSON, and I have Typescript interfaces that are generated pretty much the same way that you are proposing. Every field is optional. The reason in this case is that Protobuf PHP has no option to output JSON with default values, so a And the outcome of that is that I have to use tedious checks in many places in typescript that are not necessary at all in any of the other languages. That's just my use case of course - only elaborating as requested. But let's not forget that protobuf is intended to work across platforms and languages and there is a great benefit when implementations behave in a similar way. |
Thanks @timostamm. Yes, if all fields were optional, you'd have to do a lot of As mentioned above, I think my initial post wasn't as clear as it could have been. I propose the
With that, a variation of my original example message Person {
string name = 1;
google.protobuf.Uint32Value age = 2;
oneof legal_document {
Passport passport = 3;
DriversLicense dl = 4;
GovtIssuedId govt_id = 5;
}
} would translate to export interface Person {
name: string;
age?: number | null;
passport?: Passport | null;
dl?: DriversLicense | null;
GovtIssuedId?: govtId | null;
} So I can do |
Thanks for the clarification, @philikon. I would love to see optional properties as the default for non-scalar proto fields. But: While I agree with your thoughts about const p:Person = { name: 'pete' };
Optional properties implicitly become union types with function getPersonPassport( p:Person ): Passport | null {
// TS2322: Type 'Passport | null | undefined' is not assignable to type 'Passport | null'
return p.passport;
} The correct return type for the function would be A pragmatic solution might be to mark the properties (only non-scalar proto fields) as optional, without any union: export interface Person {
name: string;
age?: number;
passport?: Passport;
dl?: DriversLicense;
govtId?: GovtIssuedId;
}
What do you think? |
I also care about type correctness, including You mention giving non-scalars (and I'm assuming also fields within |
@philikon scalar types cannot be null. string, bool, numeric types, enums are all scalar types. For protobuf, a Members of This is enforced by the official code generated by protoc, even if the language supports null values. I you try to put a null value into a string field in C#, which allows for null strings, there will be an exception. I would generally recommend to stick to this (documented) convention. ts-proto does so for strings and bools, not sure about the others. There is one peculiarity: the wellknown type struct can store arbitrary data very similar to JSON. It can store null and also arrays that include a null value. This is implemented by wrapping the scalar types into messages. All of those messages have custom JSON serialization rules and in JSON, a StringValue looks exactly the same as a scalar string. There is also a NullValue that maps to JSON Regarding The message Demo {
oneof result {
string error = 1;
int value = 2;
}
} Can be used like this in C#: demo.Error = "message"; // will set value to 0
demo.Value = 123; // will set error to ""
switch (demo.ResultCase) {
case Demo.ResultOneofCase.Error:
// demo.Error is not ""
case Demo.ResultOneofCase.Value:
// demo.Value is not 0
case Demo.ResultOneofCase.None:
// demo.Error is "" and demo.Value is 0
} For typescript: Since ts-proto uses interfaces, it is not possible to unset other oneof members automatically when setting a member. I don't think oneof members should be made optional. It would make construction of message easier, but you would still have to take care that only one member is set. It would be possible to enforce interface Demo {
result: {
oneofCase: 'error',
error: string
} | {
oneofCase: 'value',
value: number
} | {
oneofCase: 'none'
}
}
if (demo.result.oneofCase === 'error') {
// the compiler should have narrowed down the type here
console.log(demo.result.error);
} With the drawback that the message members "error" and "value" would no longer be direct properties of the interface. TLDR: I fully agree with making all message types optional properties. I am not convinced about |
(Fwiw just chiming in that I'm really heads down at work for ~last week/through this week, so haven't been able to pay attention to ts-proto; I love the discussion you guys are having though, really appreciate it.) |
I like the way you're going with that, @timostamm. I was going to open another ticket + PR for adding a member to decoded objects that would tell you which interface Demo {
result: 'error' | 'value' | null,
error?: string,
value?: number,
} but your proposed solution is superior because it links which I propose two separate compiler flags then:
Yeah, which I think means types like https://github.com/stephenh/ts-proto/blob/master/integration/simple/simple.ts#L70-L71 are not correct and should probably be fixed. Should we file a separate ticket for that? |
Sounds perfect, @philikon. Could you open a ticket for Array<number | undefined>? I will post some reproducible example code there. |
@timostamm your proposal was this: interface Demo {
result: { oneofCase: 'error', error: string }
| { oneofCase: 'value', value: number }
| { oneofCase: 'none' },
} I find interface Demo {
result: { case: 'error', error: string }
| { case: 'value', value: number }
| { case: 'none' },
} But more importantly, I'm not sure I like the magic string I see two possible solutions: interface Demo {
enum ResultCase {
NOT_SET = 0,
ERROR = 1, // this would correspond to the protobuf field index,
VALUE = 2, // so it might be anything except 0
}
result: { case: ResultCaseERROR, error: string }
| { case: ResultCase.VALUE, value: number },
| { case: ResultCase.NOT_SET },
} (2) Instead of another type union, the interface Demo {
result?: { case: 'error', error: string }
| { case: 'value', value: number },
} I personally much prefer the latter due because of its brevity. Yes, consumers will have to check that Your thoughts? |
@philikon, The idea was that Regarding case You are right about the clash of course, I missed that. I don't see a good reason for the enum and prefer (2) as well. But does the compiler narrow down Possible solution (3): |
Fwiw this seems like a good point and worth a separate/simple PR if you'd like @philikon . I'm forgetting why we'd ever not want to include Oh, yeah, it was b/c the person who wanted to include So, @philikon maybe a PR that does: a) add a new config option of |
I like the Let's split the oneofs/ADTs out from the optional option? I.e. a |
Fwiw I've seen I think I'd prefer the |
I am also interested in
I have a one of with +25 options and would definitetly love some better erogonomics to work with this.
You can easily sidestep collisions by using a special character like Accoriding to the specification only alphanum are acceptable identifiers
|
Thanks everyone for your comments!
Agreed. I already have that pulled out and will send a separate PR. @timostamm wrote:
@stephenh wrote:
@cliedeman wrote:
All good points!
That's basically the same as an optional field, since
The optional field would still allow you to do that: interface Demo1 {
result?: { $case: 'error', error: string }
| { $case: 'value', value: number },
}
function process1(demo: Demo1) {
switch (demo.result?.$case) {
case 'error':
break;
case 'value':
break;
case undefined:
break;
default:
throw new Error('Should not get here!');
break;
}
} I will work on a PR formulating the above approach. |
… properties Addresses first part of stephenh#74
… properties Addresses first part of stephenh#74
… properties Addresses first part of stephenh#74
Released @philikon |
@philikon Any luck with this? |
…tead of individual properties Addresses stephenh#74
@cliedeman yeah, sorry, too many distractions, but PR is up now. |
…tead of individual properties Addresses stephenh#74
…tead of individual properties Addresses stephenh#74
…tead of individual properties Addresses stephenh#74
…tead of individual properties Addresses stephenh#74
I released your Is this issue good to close out now? |
It is, thanks a bunch! |
I don't find ts-proto's generated types particularly helpful because it forces me to always provide all fields (even if I set it to
undefined
), which is especially annoying when you have huge nestedoneof
s.As an example, consider the following proto:
ts-proto translates this to
If I wanted to encode one of these messages, I'd have specify all fields:
That is tedious and you can imagine with large messages, especially nested
oneof
s, it becomes entirely impractical. I'm also debating whether we should need to specifyage
andname
at this stage. proto3 says that all fields are essentially optional, thoughgoogle.protobuf.*Value
is the de-facto way to explicitly declare an optional value.(Also, I personally would also consider code that explicitly sets a value to
undefined
to be bad form. IMHO,undefined
is a fallback value that the JS/TS language provides when the thing you're trying to access cannot be found or was not defined. Code that explicitly wants to provide a null value should usenull
. That's my interpretation of JS's built-in nullish types.)I think pbts actually gets this right. It would translate the above to
which is the interface that consumed by
Person.encode()
. This means I can writeand it's perfectly valid. We can debate whether
name
andage
should be optional and nullable (probably not), but all the fields in theoneof
definitely should be! And for clarity, I would argue that any value that's not a basic type should be optional too, to save tedious typing, having to set them tonull
(or, worse,undefined
, which as said above, is not a value I'd ever expect application code to have to set, it's something to check for).I'm happy to help with the implementation, and I'm also happy to hide any changes behind a compiler flag if that's preferable.
The text was updated successfully, but these errors were encountered: