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:
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]
// ...
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!