DEV Community

Cover image for RxJS: Typing zipWith
Nicholas Jamieson
Nicholas Jamieson

Posted on • Originally published at ncjamieson.com

RxJS: Typing zipWith

This article was originally published on my blog.


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 ObservableInputs. 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 ObservableInputs, 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.


Title photo by Ricardo Gomez Angel on Unsplash.

Top comments (1)

Collapse
 
anduser96 profile image
Andrei Gatej

Great article! Learned a lot, thank you!