简体   繁体   中英

Contravariance problem in generic function

Consider the following type:

type TComp <T> = (cb: (arg: T) => void, value: T) => void;

and two implementations of this type:

const f1: TComp<number> = (cb: (a: number) => void, value: number) => {
    cb(value + 122);
}
const f2: TComp<string> = (cb: (a: string) => void, value: string) => {
    cb(value + "ok");
}

Now I introduce string names for types:

type TStrNum = 'str'|'num';

type TNameTypeMap = {
    'str': string;
    'num': number;
}

Now I want to construct function, TComp <T> such that:

const sf = <T extends TStrNum>(cb: (a: TNameTypeMap[T]) => void, value: TNameTypeMap[T], name: TStrNum) => {
    if(name==='num') {
        return f1(cb, value); //1 ts complains on `cb`
    }
    if(name==='str') {
        return f2(cb, value); //2 ts complains on `cb`
    }
}

in //1 TypeScript complains:

Argument of type '(a: TNameTypeMap[T]) => void' is not assignable to parameter of type '(arg: number) => void'.   
 Types of parameters 'a' and 'arg' are incompatible.     
  Type 'number' is not assignable to type 'TNameTypeMap[T]'.       
   Type 'number' is not assignable to type 'never'.

in //2 the same error, only complaining on string

Looks like this problem is related to contravariance of cb , but I still cannot figure out what exactly is wrong.

Is it possible to fix it or implement this function in some other way?

Before we worry about the implementation, you first should consider a problem from the caller's side of the function. By typing the name parameter as TStrNum you are not requiring that the name parameter be correlated with the value and cb parameters, so you could make the following call without the compiler complaining:

sfOrig<"num">((a: number) => a.toFixed(), 123, "str"); // no compiler error,
// RUNTIME 💥 TypeError: a.toFixed is not a function 

That's a problem. The simple way to fix that is to make name of type T :

const sf = <T extends TStrNum>(
  cb: (a: TNameTypeMap[T]) => void, 
  value: TNameTypeMap[T], 
  name: T
) => {
  if (name === 'num') {
    return f1(cb, value); // compiler error
  } else {
    return f2(cb, value); // compiler error
  }
}

This now gives mismatched inputs a compiler error:

sf((a: number) => a.toFixed(), 123, "str"); // compiler error!
// ~~~~~~~~~~~~~~~~~~~~~~~~~~
// Argument of type '(a: number) => string' is not assignable 
// to parameter of type '(a: string) => void'

sf((a: number) => a.toFixed(), 123, "num"); // okay

Now that we have the call signature working, we can worry about the implementation. A limitation of how TypeScript's type analysis works is that it does not really track the correlations between multiple expressions of union type or of generic types which are constrained to union types.

First, there's no way to tell the compiler that the type parameter T must be specified by either "str" or "num" as opposed to the full union "str" | "num" "str" | "num" . And so there's no way for the compiler to know that checking name has any implication for the type parameter T , and thus of cb and value . There's a feature request at microsoft/TypeScript#27808 asking for some way to constrain a type parameter like T to "one of" the members of a union like TStrNum , but for now there's no way to do it. You'd need to write a bunch of type assertions to convince the compiler of this:

const sf = <T extends TStrNum>(
  cb: (a: TNameTypeMap[T]) => void, 
  value: TNameTypeMap[T], 
  name: T
) => {
  if (name === 'num') {
    return f1(cb as (a: TNameTypeMap["num"]) => void, value as TNameTypeMap["num"]);
  } else {
    return f2(cb as (a: TNameTypeMap["str"]) => void, value as TNameTypeMap["str"]);
  }
}

That clears up the compiler errors but with a loss of type safety... that is, the compiler couldn't tell the difference between that and this:

const sfOops = <T extends TStrNum>(
  cb: (a: TNameTypeMap[T]) => void,
  value: TNameTypeMap[T],
  name: T
) => {
  if (name === 'str') { // <-- oops
    return f1(cb as (a: TNameTypeMap["num"]) => void, value as TNameTypeMap["num"]);
  } else {
    return f2(cb as (a: TNameTypeMap["str"]) => void, value as TNameTypeMap["str"]);
  }
}

If you want to maintain type safety without changing much from the caller's point of view, then you need to use an input data structure where checking one element (in this case the name parameter) can be used to discriminate the type of the whole data structure. The only such data structure like that in TypeScript is a discriminated union .

So instead of making the function input be three separate parameters which are constrained to correlated unions, you can make it a single rest parameter of a union of tuple types .

Here's a way to calculate that type:

type Args = { [P in TStrNum]:
  [cb: (a: TNameTypeMap[P]) => void, value: TNameTypeMap[P], name: P]
}[TStrNum]

// type Args = 
//   [cb: (a: string) => void, value: string, name: "str"] | 
//   [cb: (a: number) => void, value: number, name: "num"]

const sf = (...args: Args) => { /* impl */ }

You can see that by setting args to Args , we are really constraining calls to one of two forms: either sf((a: string)=>{}, "", "str") or sf((a: number)=>{}, 0, "num") . You can't mix and match. And Args is a discriminated union whose third element is the discriminant.

From the point of view of the implementation, there's just one more annoying issue:

const sf = (...[cb, value, name]: Args) => {
  if (name === 'num') {
    return f1(cb, value); // error!
  } else {
    return f2(cb, value); // error!
  }
}

If you try to immediately destructure the argument tuple into cb , value , and name variables, the compiler completely loses all correlation between them. This is one of the general limitations of correlated unions as reported in microsoft/TypeScript#30581 ; it might be fixed relatively soon by microsoft/TypeScript#46266 but as of now (TS4.4) it's not part of the language.

That means that we need to keep the tuple as a tuple in order to get the discriminated union narrowing. That gives this implementation:

const sf = (...args: Args) => {
  if (args[2] === 'num') {
    return f1(args[0], args[1]);
  } else {
    return f2(args[0], args[1]);
  }
}

Now everything works with no errors from both the call side and the implementation side.

Playground link to code

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM