Search code examples
typescriptrxjs

Extending (mapped) tuple types


When using tuple types in generic functions, how can you 'extend' the source type?

Let's say we want to make a RxJS mapping operator that returns the source observable value(s) together with another mapped value:

export function mapExtended<T, R>(mapping: (input: [T]) => R): OperatorFunction<[T], [T, R]>;
export function mapExtended<T1, T2, R>(mapping: (input: [T1, T2]) => R): OperatorFunction<[T1, T2], [T1, T2, R]>;
export function mapExtended<T1, T2, T3, R>(mapping: (input: [T1, T2, T3]) => R): OperatorFunction<[T1, T2, T3], [T1, T2, T3, R]>;
export function mapExtended<R>(mapping: (input: any) => R): OperatorFunction<any, {}> {
    return (source$: Observable<any>) => source$.pipe(
        map(input => {
            const mappingResult = mapping(input);
            return [...input, mappingResult];
        }),
    );
}

That seems to work but the overloads aren't detected properly. The return type for test is, Observable<[[number, number], number]> instead of the expected Observable<[number, number, number]>:

const test = combineLatest(of(1), of(2)).pipe(
    mapExtend(([s1, s2]) => s1 + s2),
);

Is there some sort of conditional type checking to say T cannot be a tuple type?


I tried accomplishing the same with mapped type support for tuples, but to no avail, as I cannot seem to (or I do not know how to) 'extend' the mapped type:

type MapExtended2Input<T> = { [P in keyof T]: T[P] };
function mapExtended2<T extends any[], R>(mapping: (input: MapExtended2Input<T>) => R): OperatorFunction<MapExtended2Input<T>, [MapExtended2Input<T>, R]> {
    return (source$: Observable<MapExtended2Input<T>>) => source$.pipe(
        map(input => {
            const mappingResult = mapping(input);
            const result: [MapExtended2Input<T>, R] = [input, mappingResult];
            return result;
        }),
    );
}

const test2 = combineLatest(of(1), of(2)).pipe(
    mapExtended2(([s1, s2]) => s1 + s2),
);

Here, the return type is also Observable<[[number, number], number]>, which is expected, but I do not know how to 'add' a type to the mapped tuple type. Intersecting doesn't seem to work or I'm doing it wrong.

An example of the desired functionality without RxJS would be:

Let's say I need a function myFunc thats has 2 generic type parameters:

  • a tuple type T, with variable number of elements
  • another type R

The result of the function should need to be a tuple type containing all elements of the tuple type T with the parameter of type R appended to it.

e.g.:

myFunc<T, R>(x, y); // Should return [T, R]
myFunc<[T1, T2], R>(x, y); // Should return [T1, T2, R]
myFunc<[T1, T2, T3], R>; // Should return [T1, T2, T3, R]
// ...

Solution

  • I think what you are asking for is: given a tuple type L (for "list") and another type T, produce a new tuple type with T appended onto the end of L. Sort of the resulting type if you were to call l.push(t). You can do that like this:

    type Push<L extends any[], T> =
      ((r: any, ...x: L) => void) extends ((...x: infer L2) => void) ?
      { [K in keyof L2]-?: K extends keyof L ? L[K] : T } : never
    

    This is a bit of trickery using rest tuples, conditional type inference, and mapped tuples...

    It's easy enough to produce the result of prepending a type onto the beginning of a tuple, since TypeScript introduced a type-level correspondence between function parameters and tuple types, and the ability to spread tuple types into rest parameters. If L is a tuple like [string, number]. then the function type (...args: L)=>void represents a function taking two parameters of types string and number respectively. Rest parameters can have stuff before them: (first: boolean, ...args: L)=>void is a function taking three parameters. And, the conditional type ((first: boolean, ...args: L) => void) extends ((...args: infer L2)=>void) ? L2 : never produces a new tuple L2 where boolean is prepended to the contents of L... such as [boolean, string, number].

    But you don't want prepending, you want appending. Well, by prepending any element to your list tuple L, we get a tuple L2 of the right length but with the wrong types. Ah, but we can map tuples! Let's map over the numeric keys K of L2, using the conditional type K extends keyof L ? L[K] : T. That is, if the numeric key K is in L ("0" or "1" if L is length 2), then just use the corresponding type from L. If not, it must be the one at the very end ("2" if L is length 2), so use the new type T there. This should have the effect of appending T to the end of L.

    Let's make sure it works:

    type P = Push<[1, 2, 3], 4>; // type P = [1, 2, 3, 4]
    

    Looks good. Now you can declare your mapExtended() like this:

    declare function mapExtended<T extends any[], R>(
      mapping: (input: T) => R
    ): OperatorFunction<T, Push<T, R>>;
    

    Note that the implementation of mapExtended() might not type check, because the compiler can't easily verify that something is of type OperatorFunction<T, Push<T, R>> for generic T and R. So you will probably want to use a type assertion or a single overload.

    I can't verify the behavior of this because I don't have rxjs installed. If you need rxjs-specific help I'm sure someone more knowledgeable about it will come along. Anyway, hope that helps. Good luck!