RxJS: Typing zipWith
February 10, 2020 • 10 minute read
Much of what we are doing with RxJS, for version 7, is focused on improving the package’s types.
RxJS version 6 was released in April 2018 and, at that time, the current version of TypeScript was 2.8. With the bump to version 7, we can update this minimum-supported version of TypeScript and can take advantage of some of TypeScript’s more recently-added features to simplify the package’s types whilst making them more accurate and more flexible.
This article, we’ll look at the types that have been declared for a new operator: zipWith
. In particular, we’ll look at how they work and at the TypeScript features upon which they rely.
What’s zipWith?
zipWith
isn’t really a new operator; it’s the deprecated zip
operator renamed.
When RxJS version 6 was released, there were a handful of operators that had the same name as observable creators: concat
; combineLatest
; merge
; race
; and zip
.
Prior to the introduction of pipe
, these operators having the same name wasn’t a problem, as operators were attached to Observable.prototype
. However, when pipeable operators were introduced, the operators became static functions that had the same name as the creators:
import { zip } from "rxjs"; // the creator
import { zip } from "rxjs/operators"; // the operator
Operators are just functions that take an observable and return an observable, so the operators were deprecated and the recommendation was to use the creators instead, like this:
const answers = of(42).pipe((source) => zip(source, of(54)));
Anyway, the above-mentioned operators are being renamed: a With
suffix is being added — yielding operator names similar to those in RxJava — and they can be used like this:
const answers = of(42).pipe(zipWith(of(54)));
zipWith
isn’t really new and it’s not especially interesting, but the types in its declaration are representative of the changes that are happening in RxJS version 7, so let’s take a look at those.
Some background on RxJS’s types
Let’s look at two areas in which the package’s types were problematic:
- union return types; and
- functions that take an arbitrary number of arguments.
Let’s look at union return types first.
Union return types
We’ll use this as our example:
const result = of(Math.random()).pipe(
concatMap((value) =>
value < 0.05
? of("Yikes, rolled a 1!")
: value < 0.95
? of(Math.floor(value * 20) + 1)
: of("Yay, rolled a 20!")
)
);
It might look a little contrived, but situations often arise in NgRx and redux-observable
in which observable streams of different actions — i.e. different types — are returned from a projection function.
In RxJS version 6.3, the snippet would not have compiled and the following error would have been effected:
Type 'Observable<number> | Observable<string>' is not assignable
to type 'ObservableInput<number>'.
The problem is that the projection function passed to concatMap
returns either an Observable<number>
or an Observable<string>
— depending upon the roll — and it’s not possible (or safe) for TypeScript to determine that Observable<number | string>
is what should be inferred. Well, not without some help.
In January 2019, Google’s Alex Rickabaugh — he’s on the Angular team — came up with a solution: the ObservedValueOf
type. And it looks like this:
type ObservedValueOf<O> = O extends ObservableInput<infer T>
? T
: never;
It extracts the value type from an ObservableInput
. And it can be used like this:
function concatMap<T, O extends ObservableInput<any>>(
project: (value: T, index: number) => O
): OperatorFunction<T, ObservedValueOf<O>>;
Using ObservedValueOf
in the concatMap
signature solves the problem and in our snippet, result
is inferred to be Observable<number | string>
. How it manages to solve the problem isn’t immediately obvious, so let’s take a closer look.
The O extends ObservableInput<any>
type parameter imposes a constraint that the projection function’s return value must be an ObservableInput
. Observable<number>
satisfies this constraint, as an Observable
is a valid ObservableInput
.
Observable<number> | Observable<string>
also satisfies the constraint as both types within the union are ObservableInput
s. That means that O
is inferred to be Observable<number> | Observable<string>
and that’s what’s passed to ObservedValueOf
.
TypeScript’s conditional types have an interesting property: they are distributive. That is, the “conditional types are automatically distributed over union types during instantiation.”
That means that:
ObservedValueOf<Observable<number> | Observable<string>>
is effectively resolved as:
ObservedValueOf<Observable<number>> | ObservedValueOf<Observable<string>>
which is:
number | string;
And that’s how ObservedValueOf
solves the problem of projection functions returning union types.
ObservedValueOf
was introduced in RxJS 6.4.0 and, unfortunately, it broke a whole bunch of Angular projects. The minimum-supported TypeScript version for RxJS version 6 was always considered to be 2.8 — as that was the latest TypeScript version when RxJS version 6 was released. However, Angular 6.1.0 depended upon RxJS ^6.0.0
and TypeScript ~2.7.2
and that version of TypeScript did not support conditional types — which were introduced in version 2.8.
Because of this breakage, there have not been many subsequent changes to the types in RxJS version 6. Improvements to the package’s types are constrained by the minimum-supported TypeScript version of 2.8 and by not being able to make changes to type parameters.
In RxJS version 7, the minimum-supported version of TypeScript will be bumped and numerous type parameters will be repurposed. We’ll look at these repurposed type parameters next, when we see how another problem was solved: passing arbitrary numbers of arguments to functions.
Arbitrary numbers of arguments
In September 2019, Ben Lesh added a type to support the passing of an arbitrary number of arguments to functions like concat
. Like ObservedValueOf
it’s a conditional type and it looks like this:
type ObservedValuesFromArray<A> = A extends Array<ObservableInput<infer T>>
? T
: never;
Prior to the introduction of ObservedValuesFromArray
, functions like concat
had to have numerous overload signatures — a signature for one argument, another for two arguments, etc. There were a limited number of these signatures — typically six — and if the number of arguments passed exceeded the limit, the type information was lost.
With ObservedValuesFromArray
, only a single signature is needed and it’s type-safe regardless of the number of arguments that are passed. Used with concat
looks like this:
function concat<A extends ObservableInput<any>[]>(
...observables: A
): Observable<ObservedValuesFromArray<A>>;
It uses TypeScript’s support for rest elements in tuple types and when called like this:
const answers = concat(of(42), of("fifty-four"));
A
will be inferred to be [Observable<number>, Observable<string>]
.
The [Observable<number>, Observable<string>]
tuple extends the array type Array<number | string>
, so ObservedValuesFromArray
will infer T
to be number | string
. And that means that answers
will be inferred to be Observable<number | string>
.
This is a huge improvement for the package’s types. The elimination of overload signatures and the correct type inference — regardless of the number of passed arguments — are significant wins. However, the change is a breaking one, as the type parameter is repurposed. Let’s take a closer look at the repurposing.
Prior to the removal of the overload signatures, the single-argument signature looked like this:
function concat<T>(o: ObservableInput<T>): Observable<T>;
So code like this would have been valid (weird and definitely not recommended, but valid nonetheless):
const result = concat<number>([]);
With the removal of the overload signatures, this will break — as the type parameter refers to the arguments’ tuple type and not the observable’s element type.
Typing zip
The types for zip
are similar to those used for concat
but there is a difference: zip
emits an array containing values received from its source observables. So the inference of an union type from a tuple type is not what’s needed. What’s needed is a mapped-tuple type.
Taking this into account, ObservedValuesFromArray
has been renamed to ObservedValueUnionFromArray
:
type ObservedValueUnionFromArray<A> =
A extends Array<ObservableInput<infer T>>
? T
: never;
And another type — that infers a tuple — has been added:
type ObservedValueTupleFromArray<A> =
A extends Array<ObservableInput<any>>
? { [K in keyof A]: ObservedValueOf<A[K]> }
: never;
This looks complicated, but what it does is reasonably straightforward: if A
is a tuple of ObservableInput
s, it maps A
to a tuple in which each element has the type of the ObservableInput
’s value — using Alex’s type.
Let’s look at an example:
type Example = ObservedValueTupleFromArray<
[Observable<number>, Observable<string>]
>;
Here, Example
will be inferred to be [number, string]
.
With the mapped-tuple type, zip
is similar to concat
and no longer requires overload signatures and correctly infers the types for an arbitrary number of arguments:
function zip<A extends ObservableInput<any>[]>(
...observables: A
): Observable<ObservedValueTupleFromArray<A>>;
Typing zipWith
The typing for zipWith
is similar to that for zip
, but there is a small difference: not all of the elements in the zipped array are present in the arguments’ tuple. The initial element in the zipped array will be the value received from the source observable to which the zipWith
operator is applied.
That is, when it’s used like this:
const answers = of(42).pipe(zipWith(of("fifty-four")));
the type of answers
needs to be inferred as [number, string]
, but [string]
is what’s inferred when ObservedValueTupleFromArray
is applied to the arguments tuple.
To get the type that we need, number
has to be be added to the head of the tuple.
We can do that using another type:
type Cons<T, A extends any[]> =
((arg: T, ...rest: A) => any) extends ((...args: infer U) => any)
? U
: never;
This type is a little tricky. It’s a conditional type that uses a relationship between two intermediate function types to infer a tuple type that adds T
at the head of A
. It works by forming an intermediate function that takes an argument of type T
followed by arguments for each of the types in A
. U
is then inferred from the arguments of the intermediate function, effectively adding T
to the head of A
.
We can use Cons
to add T
at the tuple’s head, like this:
function zipWith<T, A extends ObservableInput<any>[]>(
...observables: A
): OperatorFunction<T, Cons<T, ObservedValueTupleFromArray<A>>>;
And with the zipWith
signature declared like this, answers
— in the above snippet — is inferred to be Observable<[number, string]>
.
What’s still to do?
For version 7, the core team will be taking the types that we’ve looked here and will be applying them to other functions in the package. The goal is to get as much of the API as possible supporting the inference of correct types when an arbitrary number of arguments are passed. And to get rid of all those overload signatures.