Letharion
Letharion

Reputation: 4229

Typescript, require either of two function signatures

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

Answers (2)

jcalz
jcalz

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

Gustorn
Gustorn

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

Related Questions