Reputation: 4229
I'm working with a library that gives me a a callback, which expects either an error as it's first argument, or null as an error and a value as it's second argument.
I'm trying to encode this with a type, so typescript will validate that I'm for example not passing in both a error and a value, or neither of them.
type Callback = {
(error: undefined, value: string): void;
(error: Error): void;
}
function doThings(c: Callback) {
// Valid, no error, and a useful value.
c(undefined, '1');
// Valid, something went wrong, so we pass in an error, but no value.
c(new Error());
// Invalid. Since there's no value, there must be an error.
c(undefined);
// Invalid, there's both an error and a value.
c(new Error(), '1');
}
function c1(error: undefined, value: string) { }
function c2(error: Error) { }
doThings(c1)
doThings(c2)
Upvotes: 0
Views: 668
Reputation: 330191
As far as I can tell, your definition of Callback
is exactly as you want it to be. Callback
is a function that can be called with arguments (error: undefined, value: string)
. It is also a function that can be called with arguments (error: Error)
. A valid Callback
must support being called in both of those ways.
To reiterate, this is just fine:
type Callback = {
(error: undefined, value: string): void;
(error: Error): void;
}
function doThings(c: Callback) {
c(undefined, '1'); // okay
c(new Error()); // okay
}
And the TypeScript compiler doesn't report any errors there, which is good.
The part that seems to be a problem is that you then go ahead and define two functions, neither of which conforms to the Callback
specification. Let's look at them. First:
function c1(error: undefined, value: string) {
// some impl which expects value to be a string, e.g.,
value.charAt(0);
};
doThings(c1); // error, as expected
This function requires two arguments. You cannot call it with arguments (error: Error)
. So the call doThings(c1)
is an error by the compiler, which is exactly what you want. The compiler error tells you that c1
is not a Callback
. If you force the compiler to allow it with type assertions or other trickery, everything will be fine at compiler time, and then at runtime doThings(c1)
will end up calling c1(new Error())
, which, assuming that c1
does string-like stuff with its value
argument (as I showed above), ends up throwing an error like Error: undefined has no properties.
Similarly:
function c2(error: Error) {
// some impl which expects error to be an Error, e.g.,
console.log(error.message);
};
doThings(c2); // error, as expected
This function requires that the first argument be an Error
. You cannot call it with arguments (error: undefined, value: string)
. So the call doThings(c2)
is an error by the compiler, which is exactly what you want. The compiler error tells you that c2
is not a Callback
. If you force the compiler to allow it with type assertions or other trickery, everything will be fine at compiler time, and then at runtime doThings(c2)
will end up calling c2(undefined, '1')
, which, assuming that c2
does Error-like stuff with its error
argument (as I showed above), ends up throwing an error like Error: undefined has no properties.
So neither c1
nor c2
are valid Callback
objects. If you want to make a valid Callback
, you can do it. One way is to make a more specific function type, like so:
function cSubtype(error: undefined | Error, value?: string) {
if ((typeof error === 'undefined') && (typeof value === 'string')) {
c1(error, value);
} else if ((typeof error !== 'undefined') && (typeof value === 'undefined')) {
c2(error);
} else console.log('I got some other arguments');
}
doThings(cSubtype); // okay
The cSubtype
function is more specific than a Callback
, in that it accepts more general parameters. (Function parameters vary contravariantly, meaning that the more general/wider you make function parameters, the more specific/narrower you make the function type.) It accepts arguments like (error: undefined, value: string)
as well as (error: Error)
. It also accepts (error: Error, value: string)
and (error: undefined)
. But you can still pass cWider
to doThings
, because cWider
is a subtype of Callback
, just like a {a: string, b: boolean}
can be passed to something expecting a {a: string}
.
Another way you can do this is just to define an overloaded function which behaves exactly as you want a Callback
to behave. It still needs an implementation signature which is more general, but it cannot be called with the implementation signature (read the link about overloads for more info). Here's an example:
function cOverload(error: undefined, value: string): void; // signature1
function cOverload(error: Error): void; // signature2
function cOverload(error: undefined | Error, value?: string) { //impl
if (typeof value !== 'undefined') {
c1(undefined, value);
} else {
c2(error!);
}
}
doThings(cOverload); // works
Does that make sense? Hope it helps. Good luck.
Upvotes: 2
Reputation: 455
You can use a combination of sum and intersection types to achieve this:
type ValueCallback = (error: undefined, value: string) => void;
type ErrorCallback = (error: Error) => void;
type Callback = (ValueCallback | ErrorCallback) & ((error: Error | undefined, value?: string) => void);
function doThings(c: Callback) {
c(undefined, "1");
c(new Error());
}
function c1(error: undefined, value: string) {}
function c2(error: Error) {}
doThings(c1);
doThings(c2);
The intersection part is needed so you can actually call the function inside doThings
, without that it doesn't find a compatible call signature.
Upvotes: 2