microsoft/TypeScript

Rest type parameter in generics for use with intersection types

bryanforbes opened this issue · 20 comments

A common pattern in JavaScript libraries is to copy properties from a variable number of arguments onto the first argument. This is also the behavior of Object.assign. A simple implementation of this pattern in TypeScript might look like this:

function assign(destination: any, ...sources: any[]): any {
    sources.forEach(function (source) {
        for (let key in source) {
            if (source.hasOwnProperty(key)) {
                destination[key] = source[key];
            }
        }
    });
    return destination;
}

This works fine, however with intersection types in 1.6 this could be better:

function assign<T, U>(destination: T, ...sources: U[]): T & U {
    ...
}

This works for one argument, but more than one fail because U isn't actually the type needed for the intersection. What is needed (as suggested here) is a rest type parameter. It might look something similar to the following:

function assign<T, ...U>(destination: T, ...sources: U[]): T & U {

U would be the intersection of all rest parameters passed to assign() and the return value would expand to something like T & (U0 & ... UN). With the push for composable types and ES6 standardizing the above behavior (in Object.assign) a syntax for generics like this would be very useful.

This has definitely come up before at least in conversation/comments (particularly in the context of intersection types + mixins) but I can't find an issue for it at the moment.

I think it could make sense to declare a family of type parameters <T, ...U>. For type argument inference, this could mean that U is actually the list of all the inference candidates, rather than their common type.

The thing is, we would have to define a set of spread operators that apply to type parameter families. Intersection and union are certainly prime candidates. Tuples are perhaps another, and maybe even parameter lists.

I think we could say that it is illegal to spread a type parameter family in its parameter list. It can only be spread in the return type.

Oh wait, there is a problem. Of all the spread operators I have suggested, only union is commutative. That is bad for all the others, because if we spread inference candidates in a non-commutative way, we will surface the order of the inference candidates. I think we do not want to do that.

We could say that a rest type parameter can only be used in a rest parameter, and spread in the return type. And then it would be a family of types formed in the following way:

  • The family would have one element for each argument matching the rest parameter.
  • Within the rest parameter, if the type parameter is referenced multiple times, then you must take the common supertype of all the inferences made from within a single argument. That way, the only order we surface in the family is the order of the arguments the caller passed, which is not controversial.

As I mentioned on #3622, there are actually two things needed for this to work: indicating that a generic type for a rest parameter is a union type (rather than a common sub-type of all of the rest parameters) and a way to intersect those with another type so they end up being an intersection of all (T & U0 & U1 rather than T & (U0 | U1)).

For the first (indicating a union instead of a common supertype), I'm not sure if it would be better in the generic type parameters or in the function parameters:

function assign<T, ...U>(target: T, ...sources: U[]): /* return type TBD */ {

function assign<T, U>(target: T, ...sources: ...U): /* return type TBD */ {

For the second (converting a union to an intersection), it might be nice to use &&:

function assign<T, ...U>(target: T, ...sources: U[]): T && U {

I think we want to declare up front whether U is a single type parameter or a family of type parameters. Allowing U in some places and ...U in others (in the parameter list and return type) will make it confusing. So I would prefer to declare ...U in the type parameter list, and spread it in the return type.

I do not think there is anything magical about unions. I think it makes more sense to just think of U as a family of type parameters (with no particular operation in mind). The operation can be determined in the return type when you spread U.

I think there are two possible ways to handle spreading. One is to say that T & ...U means that the elements of U are intersected. However, this does not allow you to write something equivalent to T & (U0 | U1) or T & [U0, U1]. That may be fine. If we want more expressiveness though, we may want to make the spread specify which aggregator to use, something like T & ...&U or T & ...|U. I actually prefer the first option, where the aggregation mechanism is determined by the parent operator.

For starters, there are three places we could allow spreading:

  1. Intersection: T & ...U
  2. Union: T | ...U
  3. Tuples: [T, ...U]

Oh, there's a problem. Because of the ES6 spread operator, the compiler might not know how many arguments were passed. Suppose we treat each spread argument as one element of the family. That is consistent, but we might get the final arity of the family wrong. To that end, we cannot spread the family in any arity-sensitive way. So we could only spread in intersections and unions, but not tuples. I think this is fine, and @bryanforbes you are asking for intersection, so it would solve your issue.

Since tuples extend a union of their constituent types for indices higher than the explicitly specificed member types, won't they still work?

But tuples have an arity, and I'm saying that the arity would have to depend on how many arguments were passed. But the number of arguments cannot be known statically when it is a spread argument.

function manyInTupleOut<...T>(...params: Array<...T>): [...T];

var input: [number, string, Document];
var output = manyInTupleOut(...input);

You're saying that ...input expands to an unknown number of elements, so the specialization of manyInTupleOut is unknown.

I'm saying that input's type is known to be [number, string, Document, number|string|Document, number|string|Document, ...] even if the actual number of elements is not known. So the specialization of manyInTupleOut can be <number, string, Document, number|string|Document, number|string|Document, ...>. Thus output will be of type [number, string, Document, number|string|Document, number|string|Document, ...] which is indistinguishable from the intended [number, string, Document]

It doesn't seem necessary to know the number of elements in input.

Ah I see what you're saying. But the arity does matter, because you are not allowed to assign a shorter tuple to a longer tuple type.

var a: [number, string];
var b: [number, string, number | string];

a = b; // Allowed
b = a; // Error

Oh, didn't know that.

Just a note on another (related?) use-case, but I was thinking about how/if this could be used to describe partial application? Right now I write out each combination manually which increases exponentially in size.

function partial <T, ...U> (fn: (...args: U[]) => T, ...args: U[]): (/* What goes here? */) => T

@blakeembrey How would the function decide how many arguments to "use up" in the partial application? Why would it even need to take all of them, if it is only going to apply the function to some of the arguments?

@JsonFreeman Honestly, not sure, I haven't thought on it for long enough - just saw it was similar - so I posted it hoping someone smarter than I could toil with it for a moment. The only syntax I can think of would be two spread arguments - though illegal.

function partial <T, ...U, ...V> (fn: (...args: U[], ...illegal: V[]) => T, ...args: U[]): (...args: V[]) => T

Oh, I think I misunderstood. I see what you are trying to do now. You're right, I don't think the proposal here could achieve it because it requires selecting only a portion of the type parameters in the family. I am not sure whether it is better to propose an alternative higher order generics scheme that solves this and the original issue together, or if they are better explored separately.

I don't think intersection is special. I think there are a number of ways you might want to combine multiple types. So I think it does not make sense to assume that ...T would be intersection. Also, in that case, what would it mean if you just referenced T instead of ...T?

@jbondc

Current behavior is typeof mixA which I don't really understand.

It's because mixA, mixB and mixC are all the same type {}, so typeof mixA is a valid specialization for T. Try adding unique members to them.

Related to the issue on variadic generics: #1773

closing in favor of #1773