typescripttypescript-generics

How to set generic constraint in a returned function to contain a subset of a previous generic?


I know this must be a duplicate but I've searched a while and can't find the resources I need to understand the issue.

I'm trying to type a functional programming approach to mutating an array.

e.g. I want this behavior (playground example)

const pushAll = <Values extends ArrayValues, ArrayValues>(
  values: Values[],
  arrayValues: ArrayValues[]
) => {
  arrayValues.push(...values)
  return arrayValues
}

but to work functionally e.g. (playground example)

// is it possible to constrain `ArrayValues` to contain a subset `Values` ?
const pushAll = <Values>(values: Values[]) => <ArrayValues>(arrayValues: ArrayValues[]) => {
  arrayValues.push(...values)
  return arrayValues
}

Thanks


Solution

  • Your original pushAll is of type

    declare const pushAll: <V extends A, A>(values: V[], arrayValues: A[]) => A[]
    

    But you can't split it into a curried version like

    declare const pushAll: <V extends A>(values: V[]) => <A>(arrayValues: A[]) => A[]
    

    because then V would need to be constrained to be a subtype of A, but A doesn't exist in that scope. A won't come into scope until someone calls the returned function.


    For this to work at all, you'd want to change your constraints so that V is completely unconstrained, and then A is constrained to be a supertype of V. Essentially you want the same restriction as V extends A but where V is fixed and A is constrained, not vice versa. That sort of constraint is called a lower bound constraint, and in languages where it exists is usually written A super V. The constraints V extends A and A super V represent the same type relationship in much the same way as v ≤ a and a ≥ v represent the same mathematical relationship. So, conceptually, you'd want to rewrite your original pushAll as

    declare const pushAll: <V, A super V>(values: V[], arrayValues: A[]) => A[]
    

    and then split it into the curried form as

    declare const pushAll: <V>(values: V[]) => <A super V>(arrayValues: A[]) => A[] 
    

    Unfortunately, TypeScript does not have lower-bound constraints. There is a longstanding open feature request for it at microsoft/TypeScript#14520, but it's not part of the language. Until and unless it's implemented, you'll need to work around it.


    You can mostly emulate lower bound constraints using conditional types, at least from the point of view of the caller of pushAll. If I want to write A super V, but the only way to constrain A is with A extends ⋯, then I need to fill in with some type that succeeds if and only if A super V is true (i.e., V extends A is true). So I can write A extends ([V] extends [A] ? unknown : never). If A super V is true, then [V] extends [A] is true, and so the conditional type evaluates to unknown, and now we're checking A extends unknown which is always true. If A super V is false, then [V] extends [A] is false, and so the conditional type evaluates to never, and now we're checking A extends never which is almost always false (unless A is never, but that's usually an unlikely edge case). And note that I'm doing [V] extends [A] instead of V extends A because we don't want a distributive conditional type (which would behave strangely for unions).

    So that gives us

    declare const pushAll: <V>(values: V[]) => 
      <A extends ([V] extends [A] ? unknown : never)>(arrayValues: A[]) => A[]
    

    Let's see how that works:

    pushAll([1, 2])([3, 4]) // okay
    pushAll([1, 2])(['a', 1]) // okay
    pushAll([1, 'a'])([2, 'b']) // okay
    
    pushAll([1, 2])(['a', 'b']) // error!
    //               ~~~  ~~~ 
    // Type 'string' is not assignable to type 'never'.
    pushAll([1, 'a'])(['b', 'c']) // error!
    //                 ~~~  ~~~
    // Type 'string' is not assignable to type 'never'.
    

    Looks good. The success cases still succeed, and the fail cases still fail. The errors are in different places for the failures, but that's to be expected; you can't go back in time and have the call to pushAll() itself fail. All you can do is refuse to accept the call to the returned function. In a world where A super V worked in TypeScript, the errors would be in the same place but the wording would say something like "Type 'number' is not assignable to type 'string'" or "Type 'number | string' is not assignable to type 'string'".


    Anyway, this works for callers. The implementation is trickier because TypeScript really doesn't understand generic conditional types. It doesn't "see" that A extends ([V] extends [A] ? unknown : never) implies that V extends A is true for generic A. So it will balk at the push() line:

    const pushAll = <V>(values: V[]) => 
      <A extends ([V] extends [A] ? unknown : never)>(arrayValues: A[]) => {    
      arrayValues.push(...values); // error!
      //               ~~~~~~~~~
      // Argument of type 'V' is not assignable to parameter of type 'A'.
      return arrayValues
    }
    

    You'll need a workaround. You could always widen arrayValues from A[] to (A | V)[]. This is safe because if we expect V extends A to be true, then A | V is essentially the same type as A. And TypeScript allows widening of array element types (even though it's generally unsafe to allow pushing stuff onto widened arrays like that, see Why are TypeScript arrays covariant?) So we can write:

    const pushAll = <V>(values: V[]) => 
      <A extends ([V] extends [A] ? unknown : never)>(arrayValues: A[]) => {    
      const av: (A | V)[] = arrayValues;
      av.push(...values)
      return arrayValues
    }
    

    and it compiles now without error, and does what you like. Again, if TypeScript natively supported A super V then presumably it would have no problem with arrayValues.push(...values). And again, in the absence of that feature, we need workarounds like this.

    Playground link to code