Closed arluko closed 7 months ago
@arluko Hi! Thanks for the report! This looks very reasonable. Lemme try to work on a fix. Thanks!
(although, this might be a breaking change to existing users so maybe i need to be a bit cautious about releasing :))
@arluko Actually, on another thought, on the callback method, only one of err
and response
will be null, but NOT both at the same time.
In your code example, most people would probably want to do something like:
this._apiClient.setFoo(request, {}, (err, response) => {
if (err) {
reject(err);
} else {
resolve(response); // <=============== resolve with response
}
});
But if i make both types nullable, then they have to check null for both of them, which makes it rather inconvenient..
like maybe:
this._apiClient.setFoo(request, {}, (err, response) => {
if (err) {
reject(err);
} else if (response) {
resolve(response); // <=============== resolve with response
} else { // <========== this will NEVER happen..
}
});
I'd think the latter inconvenience might outweight the warning that you see..
And given the fact that this will be a breaking change for existing users, i'm leaning towards not making the change..
@arluko What do you think? Is there an easier way for clients to work around the inconvenience? Thanks!
(@stanley-cheung CC FYI)
@sampajano As far as i know the only solution to setup typing which matches the runtime behavior is with discriminated unions (see example bellow), but this would required to change the interface of the callback which breaks every implementation.
// Example code snippet for a solution with discriminated unions:
// see https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions
type RpcResponse = { bar: string };
type RpcError = { msg: string };
type FooResult = { success: true, response: RpcResponse } |
{ success: false, error: RpcError };
class Demo {
foo(result: FooResult): void {
if (result.success) {
console.log(result.response); // => response is `RpcResponse`
} else {
console.log(result.error); // => error is `RpcError`
}
}
}
Another way without completely breaking everything would be to type only one of the parameters as nullable, which is not (entirely) correct, but would allow to mitigate the issue you described in the second code snippet.
setFoo(
request: xyz_api_pb.FooRequest,
metadata: grpcWeb.Metadata | null,
callback?: (
err: grpcWeb.RpcError | null,
response: xyz_api_pb.FooResponse) => void // response is not typed as nullable to make usage simple
) {
// ...
}
What do you think about this option?
My current workaround is to prefix each of the error guards with an eslint-disable comment to turn off the rule for the next line. This works but you have to do this for (nearly) every service call and I had the hope that we can adjust the generated code somehow to mitigate this.
this._apiClient.setFoo(request, {}, (err, response) => {
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (err) {
reject(err);
} else {
resolve(response);
}
});
@arluko Thanks so much for the ideas!
As far as i know the only solution to setup typing which matches the runtime behavior is with discriminated unions
I totally agree that the discriminated union is the better solution, but i don't think we can afford breaking every implementation..
Another way without completely breaking everything would be to type only one of the parameters as nullable, which is not (entirely) correct, but would allow to mitigate the issue you described in the second code snippet.
This is pretty smart! Although i do not really like the asymmetry (and incorrectness) either.. especially not everyone would check for error and response using the example i provided..
This works but you have to do this for (nearly) every service call and I had the hope that we can adjust the generated code somehow to mitigate this.
I agree 100% that this is very sub-optimal and definitely should be improved on. But since we're anyways going to make a breaking change, i think we should aim to do it right.. (else the gain does not warrant the cost (breaking the users) IMO.)
Regarding discriminated unions, I actually haven't worked much on Typescript myself.. Would something like the following work?
type FooResult = { error: null, response: RpcResponse } |
{ error: RpcError, response: null };
Thanks!!
@sampajano According to this example your suggestion should work and the types are resolved correctly.
@arluko Thanks so much for providing the link to the TS Playground! Good to learn!
Although.. I've only just realized that this again requires making a breaking change to all clients, since it is now returning one object instead of two..
I'm not sure if there's a good fix without breaking all users (which we cannot afford)..
Ideas are welcome.. :)
@sampajano I think there is no good/correct solution without a breaking change. The only other way I can currently think of is the idea from https://github.com/grpc/grpc-web/issues/1309#issuecomment-1373228077, but I understand why you dont like it and I like it neither.
I have a workaround , so it is not a big issue for me anymore, but maybe you can consider it again if some other breaking changes are introduced into the project anyways.
What about doing a breaking change following more or less the same strategy as other major programming languages do? That is:
WDYT?
What about doing a breaking change following more or less the same strategy as other major programming languages do?
@glerchundi Thanks a lot for the suggestion! It makes a lot of sense to me!
Although note that this will involve creating and maintaining 2 runtime behaviors in the following code, which we happen to share with streaming RPC handling as well..
I think this will be a fairly involved change / process so unfortunately it won't be my priority at this moment.. If this issue gets a lot of demands i'll consider prioritizing it further.
In any case contributions are very welcome too! :)
When we do change it, my current preference is to change it to the following, as we've discussed above:
type RpcCallResponse = { error: null, response: RpcResponse } |
{ error: RpcError, response: null };
And we update rpcCall to:
rpcCall<REQ, RESP> (
method: string,
request: REQ,
metadata: Metadata,
methodDescriptor: MethodDescriptor<REQ, RESP>,
callback: (RpcCallResponse) => void
): ClientReadableStream<RESP>;
Actually on another thought, would you be able to use thenableCall
which does not suffer from the same typing issue?
Closing for now due to inactivity. Feel free to reopen if you're still interested in following up :)
I am working with an angular application and eslint. When I enable the
@typescript-eslint/no-unnecessary-condition
rule (see https://typescript-eslint.io/rules/no-unnecessary-condition) it triggers a linting error which is cause by the generated code from thegrpc_generator.cc
file.Excerpt of the generated code:
Example implementation of the service with angular:
The issue is that
err
is typed asgrpcWeb.RpcError
instead ofgrpcWeb.RpcError | null
. The linter checks the if statement and detect it is not necessary, because the generated type states it is always agrpcWeb.RpcError
, which is not true because it can benull
if the request succeeds.Can you adjust the generator to include the nullable type?