Create your own
Lesson illustration

Implementing a Type-Safe `pipe` Method on `Observable`

Hello! Welcome back to our course on building a modern JavaScript framework.

In the previous module, we laid the essential groundwork by implementing the core Observable, Observer, and Subscription classes. You now have a system that can create an observable, subscribe to it, receive values, and tear down the subscription.

Today, we'll build on that foundation to make our observables much more powerful and expressive. The goal for this lesson is to implement a pipe method on the Observable prototype for composing operators, using function overloads for type safety across multiple operators. This will allow us to chain operations together in a clean, readable, and type-safe manner, which is a cornerstone of reactive programming.

1. The "Why" and "What" of Pipeable Operators

In complex applications, you often need to perform a series of transformations on a stream of data. Without a composition mechanism, you might end up with deeply nested or chained new Observable(...) calls, which can become hard to read and maintain. This is sometimes called the "mega observable" problem.

A much cleaner approach is to use a series of modular, reusable "operators" that can be chained together. The pipe method is the mechanism that enables this chaining.

So, what exactly is an operator in this context? Let's watch a short clip from a talk by Ben Lesh, the project lead for RxJS, who explains this concept very clearly.

How To Build Your Own RxJS Operators | Ben Lesh & Tracy Lee

In this video, 'How To Build Your Own RxJS Operators', Ben Lesh explains the fundamental anatomy of a pipeable operator and how the pipe method composes them.

Please watch the section from 07:41 to 08:54. Focus on the definition of an operator as a function that transforms one observable into another, and how pipe simply applies a list of these functions in sequence.

As you saw, an operator is simply a function that takes a source Observable and returns a new Observable. The pipe method's job is to take any number of these operator functions and apply them sequentially.

Let's formalize this with a TypeScript type alias. We'll create a file, perhaps src/operators.ts, to hold this and future operator-related code.

// src/operators.ts
import { Observable } from './observable';

export type OperatorFunction<T, R> = (source: Observable<T>) => Observable<R>;

This generic type OperatorFunction<T, R> represents a function that takes an Observable emitting values of type T and returns an Observable that emits values of type R.

2. A Simple (but Type-Unsafe) pipe Implementation

Now, let's add the pipe method to our Observable class. The core logic is surprisingly simple: we can use Array.prototype.reduce to pass the Observable through each operator in the chain.

The article "Building a Reactive System in TypeScript" provides a very clean implementation of this idea.

Building a Reactive System in TypeScript - 5

This article walks through building a reactive library. We'll focus on the section that implements the pipe method.

Read the section titled 'Composing with pipe'. Pay close attention to the pipe function's implementation using reduce. Note how the accumulator (acc) is the observable and the current value (op) is the operator function.

Based on that, we can add a preliminary version of pipe to our Observable class in src/observable.ts. For now, we'll use any for the types, which we will fix shortly.

// In src/observable.ts, inside the Observable class

import { OperatorFunction } from './operators'; // Make sure to import this

// ... existing class properties and methods ...

pipe(...operators: OperatorFunction<any, any>[]): Observable<any> {
  return operators.reduce(
    (source: Observable<any>, operator: OperatorFunction<any, any>) => operator(source),
    this
  );
}

This implementation works functionally. It takes an array of operators and reduces them, starting with this (the source observable). Each step of the reduction applies the next operator to the result of the previous one.

However, there's a major problem: we've lost all type information. No matter what operators you pass, the result is always Observable<any>. If you pipe a map(n => n.toString()) onto an Observable<number>, you'd want the result to be Observable<string>, not Observable<any>.

3. Achieving Type Safety with Function Overloading

To solve this type-safety issue, we'll use a powerful TypeScript feature: function overloading. This allows us to provide multiple, specific type signatures for a single function implementation.

Let's first get a clear understanding of how function overloading works in TypeScript.

No BS TS #4 - Function Overloading in Typescript

This 'No BS TS' video by Jack Herrington provides a concise and practical guide to function overloading in TypeScript.

Watch from the beginning until 06:50. Notice the pattern: you define several specific function signatures without an implementation, followed by a single, more general implementation signature that can handle all the cases.

Now we can apply this pattern to our pipe method. We'll define a series of overload signatures, each one handling a different number of operators. Each signature will correctly track the type as it flows through the pipeline. The final signature will be our general, type-unsafe implementation signature that makes the code run.

The same article we looked at earlier demonstrates this exact technique. It's the standard approach used by major libraries like RxJS.

Building a Reactive System in TypeScript - 5

Let's return to the 'Building a Reactive System' article to see how they solve the typing problem for pipe.

Read the section at the end titled 'One more thing...'. This section provides a series of overload signatures for a PipeFn interface. We will adapt this directly for our pipe method in the Observable class.

Let's update our Observable class with these overloads. We'll add them directly above our existing pipe implementation.

Your Task: Implement the Type-Safe pipe Method

Update the Observable class in src/observable.ts with the following code. I've provided the first few overloads; try adding the signatures for 4 and 5 operators yourself to solidify the pattern.

// In src/observable.ts

import { Observer } from './observer';
import { Subscription } from './subscription';
import { OperatorFunction } from './operators';

// The type for the function passed to the Observable constructor
export type Producer<T> = (observer: Observer<T>) => (() => void) | Subscription | void;

export class Observable<T> {
  private producer: Producer<T>;

  constructor(producer: Producer<T>) {
    this.producer = producer;
  }

  subscribe(observer: Observer<T>): Subscription {
    // ... (existing implementation from previous lesson)
  }

  // --- NEW: Add pipe method with overloads ---

  // No operators: returns the same observable
  pipe(): Observable<T>;
  // 1 operator
  pipe<A>(op1: OperatorFunction<T, A>): Observable<A>;
  // 2 operators
  pipe<A, B>(
    op1: OperatorFunction<T, A>,
    op2: OperatorFunction<A, B>
  ): Observable<B>;
  // 3 operators
  pipe<A, B, C>(
    op1: OperatorFunction<T, A>,
    op2: OperatorFunction<A, B>,
    op3: OperatorFunction<B, C>
  ): Observable<C>;

  /*
   * TODO: Add the overloads for 4 and 5 operators here.
   * Follow the pattern:
   * - Add a new generic type parameter for each new operator's output.
   * - The input type of a new operator is the output type of the previous one.
   * - The return type of the pipe method is the output type of the final operator.
   */

  // Implementation signature (remains the same)
  pipe(...operators: OperatorFunction<any, any>[]): Observable<any> {
    return operators.reduce(
      (source: Observable<any>, operator: OperatorFunction<any, any>) => operator(source),
      this
    );
  }
}

How it works:

  • TypeScript's compiler checks the arguments you pass to pipe against the list of overload signatures.
  • It finds the first signature that matches. For example, if you pass two operators, it matches the second overload: pipe<A, B>(...).
  • It then uses the generic parameters (A, B, etc.) and the return type from that specific signature (Observable<B>) to infer the final type.
  • The underlying implementation, which uses any, is only used for the runtime logic and is not visible to the consumer of the method from a type-checking perspective.

For Further Exploration: The Limits of Overloading

You might be wondering if there's a more elegant way to type pipe without writing out numerous overloads. This is a well-known and challenging problem in TypeScript's type system. While variadic tuple types and conditional types have opened up new possibilities, the overload approach remains the most common and robust solution for now.

For a deeper dive into the problem space, you can explore this long-standing issue on the TypeScript GitHub repository. It's a fascinating read that shows how the community and the TypeScript team have approached this complex typing challenge.

Pipe/flow/chain type support · Issue #30370

This GitHub issue discusses the challenges and potential solutions for typing pipe/flow functions in TypeScript.

This is an optional, advanced reading. Skim through the initial post (part 0) to understand the problem statement. Then, look at the comments by 'profound7' (part 2) and 'Azarattum' (part 3) to see some modern, recursive type-level solutions. This will give you an appreciation for the complexity involved and why the overload pattern is so prevalent.


Conclusion

In this lesson, we've significantly enhanced our Observable class by adding a composable pipe method.

Key Takeaways:

  • Pipeable Operators are functions with the signature (source: Observable<T>) => Observable<R>.
  • The pipe method provides a clean and readable way to chain these operators.
  • The core implementation of pipe can be written concisely using Array.prototype.reduce.
  • TypeScript function overloads are essential for providing strong, end-to-end type safety for the pipe method, ensuring the output type correctly reflects the transformations applied.

Preview of the Next Lesson:
Our pipe method is ready, but we don't have any operators to use with it yet! In the next lesson, we will implement several fundamental static factory functions (of, from, interval) to create observables from different kinds of data sources. This will give us the building blocks we need to start creating and manipulating real data streams.

Can't find a good explanation? Sign up and we'll make it for you

Sign up