Recommended resources
The freely available resources this lesson will direct you to.
Hello! Welcome back to our course on building a modern JavaScript framework.
In our last lesson, we implemented the merge operator, which acts like a funnel, emitting values from multiple source observables as they arrive. This was our first foray into combining streams.
Today, we'll continue in Module 5: Combination & Timing by implementing another fundamental combination operator: combineLatest. While its name sounds similar to merge, its behavior is quite different and serves a distinct purpose: synchronizing the latest state from multiple streams.
Our learning goal is to implement and test a static combineLatest operator, using tuple types to correctly type the combined array of latest values. This will require us to dive deeper into TypeScript's type system to ensure our operator is not only functional but also fully type-safe.
1. Understanding combineLatest
combineLatest creates an observable that emits an array containing the latest value from each of its source observables. It has two key characteristics:
- It will not emit any value until every source observable has emitted at least once.
- After the initial emission, it will emit a new array whenever any of the source observables emits a new value. This new array will contain the newly emitted value along with the last-emitted values from all other sources.
To visualize this, let's start with the official RxJS documentation.
The official RxJS documentation provides the definitive explanation of combineLatest, including a marble diagram that clearly illustrates its behavior.
Please read the 'Description' section and study the marble diagram. Then, review the first example, 'Combine two timer Observables'. Focus on when the first output occurs and how subsequent outputs are generated.
As you can see, combineLatest is perfect for scenarios where you need a combined view of the state of several independent streams. For example, calculating a value based on the latest inputs from several form fields.
2. The Typing Challenge: From Union Types to Tuples
In the merge operator, the output was Observable<string | number> because the stream could emit either a string or a number at any time. With combineLatest, the output is more structured: it's an array where the element at index 0 always corresponds to the first input observable, the element at index 1 to the second, and so on.
If we combine Observable<string> and Observable<number>, we don't want an Observable<(string | number)[]>. This type is too loose; it allows [1, 'hello'], which is incorrect. We need an Observable<[string, number]>. This is where TypeScript tuples become essential.
A tuple is an array with a fixed number of elements whose types are known. To get a quick refresher on them, please watch the following video.
TypeScript Tutorial #20 - Tuples
This video from the Net Ninja channel provides a clear and concise introduction to tuples in TypeScript, contrasting them with regular arrays.
Watch the sections 'Introduction to Tuples' (00:00) and 'Explicitly Defining and Using Tuples' (01:34). Pay close attention to how a tuple enforces a specific type at a specific position in the array.
This exact typing problem is a common point of confusion when using combineLatest. The following Stack Overflow thread perfectly illustrates the issue and why tuples are the correct solution.
typescript - Retain the type of mutiple Observable<T> using ...
This Stack Overflow question shows a developer facing the exact typing problem we are solving: combineLatest producing an any[] and losing type information. The answers highlight the use of tuples.
Read the original question to understand the problem, then review the second answer ('This sounds like a use case for tuples'). This directly validates our approach.
3. A Type-Safe Signature with Mapped Types
To achieve our desired Observable<[string, number]> output, we need a sophisticated generic signature that can transform a tuple of Observable types into a tuple of their inner value types. We can do this with a mapped type.
// A helper to extract the inner value type from an Observable
type ObservedValue<O> = O extends Observable<infer T> ? T : never;
// A mapped type that transforms a tuple of Observables into a tuple of their values
type ObservedValues<T extends readonly Observable<any>[]> = {
[K in keyof T]: ObservedValue<T[K]>;
};
export function combineLatest<T extends readonly Observable<any>[]>(
sources: [...T]
): Observable<ObservedValues<T>> {
// Implementation goes here
}
Let's break down this signature:
T extends readonly Observable<any>[]: We constrainTto be a tuple of observables. Usingreadonlyhelps TypeScript preserve the tuple structure.sources: [...T]: Using a rest parameter in the arguments also helps TypeScript inferTas a tuple type rather than widening it to a regular array type (e.g.,Observable<string | number>[]).ObservedValues<T>: This is the core of our solution. It's a mapped type that iterates over each keyKin the tupleT(which are the indices '0', '1', etc.) and applies ourObservedValuehelper to each observable in the tuple.- Input:
[Observable<string>, Observable<number>] - Output:
[string, number]
- Input:
This ensures that our combineLatest function will have a perfectly typed output tuple.
4. Implementation Strategy
The implementation of combineLatest is more stateful than merge. The producer function returned by our operator will need to manage several pieces of state:
latestValues: An array to store the most recent value from each source observable.hasEmitted: A boolean array to track which sources have emitted at least once.emittedCount: A number to track how many sources have emitted. We can only start producing output when this equals the total number of sources.completedCount: A number to track how many sources have completed. The output stream completes when this number equals the total number of sources.
The logic for the inner observer provided to each source will be:
next(value):- Check if the source has emitted before. If not, mark it as having emitted and increment
emittedCount. - Update the
latestValuesarray at the source's index with the newvalue. - If
emittedCountequals the total number of sources, it means all streams are now active. Emit a copy of thelatestValuesarray to the main observer.
- Check if the source has emitted before. If not, mark it as having emitted and increment
error(err): Immediately propagate the error to the main observer and clean up.complete():- Increment
completedCount. - If
completedCountequals the total number of sources, complete the main observer.
- Increment
- Teardown: As with
merge, ensure that unsubscribing from the maincombineLatestobservable unsubscribes from all inner source observables.
5. Your Task: Implement and Test combineLatest
Now it's time to implement the operator. Create a new file src/operators/combineLatest.ts and use the following skeleton.
// src/operators/combineLatest.ts
import { Observable } from '../observable';
import { Subscription } from '../subscription';
// Helper types from section 3
type ObservedValue<O> = O extends Observable<infer T> ? T : never;
type ObservedValues<T extends readonly Observable<any>[]> = {
[K in keyof T]: ObservedValue<T[K]>;
};
export function combineLatest<T extends readonly Observable<any>[]>(
...sources: T
): Observable<ObservedValues<T>> {
return new Observable(observer => {
if (sources.length === 0) {
observer.complete();
return new Subscription();
}
const subscriptions = new Subscription();
const latestValues = new Array(sources.length);
const hasEmitted = new Array(sources.length).fill(false);
let emittedCount = 0;
let completedCount = 0;
sources.forEach((source$, i) => {
const innerSub = source$.subscribe({
next: value => {
latestValues[i] = value;
if (!hasEmitted[i]) {
hasEmitted[i] = true;
emittedCount++;
}
if (emittedCount === sources.length) {
// Important: emit a copy of the array
observer.next([...latestValues] as ObservedValues<T>);
}
},
error: err => {
observer.error(err);
},
complete: () => {
completedCount++;
if (completedCount === sources.length) {
observer.complete();
}
},
});
subscriptions.add(innerSub);
});
return subscriptions;
});
}
Testing Your Implementation
Create a corresponding test file src/operators/combineLatest.test.ts. Your tests should verify all aspects of the operator's behavior.
Test Cases to Cover:
-
Type Safety (Compile-time check):
const result$ = combineLatest(of('A'), of(1));- Verify that the type of
result$is inferred asObservable<[string, number]>.
-
Initial Emission Logic:
- Use
vitest.useFakeTimers()and two delayed observables (e.g., using a custom observable withsetTimeout). - Assert that no value is emitted until both sources have emitted their first value.
- Assert that the first emitted value is an array containing the first value from each source.
- Use
-
Subsequent Emissions:
- Continue from the previous test. Trigger a new emission from one of the sources.
- Assert that a new array is emitted immediately, containing the new value from the triggered source and the old value from the other.
-
Completion Logic:
- Combine two finite observables (e.g.,
of('a', 'b')andof(1, 2)). - Assert that the
completecallback is called only after both sources have completed.
- Combine two finite observables (e.g.,
-
Error Handling:
- Combine a valid observable with one that errors.
- Assert that the
errorcallback is triggered on the combined stream as soon as the source errors.
-
Unsubscription:
- Combine two
intervalobservables. - Subscribe and then immediately unsubscribe.
- Use spies or advance timers to ensure no values are ever emitted to the observer's
nextmethod, proving the inner subscriptions were torn down.
- Combine two
For inspiration on combining streams in a component, you can review this short video. While it uses Angular, the core RxJS pattern of combining streams and then mapping the result to a more convenient object is a universal and useful technique.
Learn RxJS Angular, RxJS pipe, async pipe, RxJS observables, Behaviorsubject, Combinelatest
This clip from Monsterlessons Academy demonstrates a practical use case for combineLatest in a component, showing how to merge multiple data streams into a single, easy-to-use object.
Watch the section 'Combining Streams with combineLatest' (15:36 - 17:47). Notice how they pass an array of observables to combineLatest and then use pipe(map(...)) to transform the output array [users, usernames, ...] into an object { users, usernames, ... }. This is a very common and clean pattern.
Conclusion
Excellent work! You've now implemented combineLatest, a powerful operator for managing and synchronizing state from multiple sources. You've also tackled a significant typing challenge, using TypeScript's mapped and tuple types to create a fully type-safe implementation.
Key Takeaways:
combineLatestwaits for all sources to emit once, then emits an array of the latest values whenever any source changes.- It is fundamentally different from
merge, which simply passes through all values as they arrive. - Tuple types are the correct way to type the output of
combineLatestto preserve positional type information. - Advanced generic patterns, like mapped types, allow us to create flexible and type-safe function signatures that automatically infer these complex tuple types.
In our next lesson, we will shift our focus from combining streams to controlling their timing. We'll dive into implementing operators like delay, debounceTime, and throttleTime, making extensive use of Vitest's fake timers to test their asynchronous behavior deterministically.