Create your own
Lesson illustration

Type-Safe Map Operator with Output Type Inference

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

In the previous modules, you've laid a fantastic foundation: setting up the project with TypeScript and Vite, and then building the core reactive architecture with Observable, Observer, and Subscription. You also implemented the crucial pipe method, which is the gateway to composing functionality.

Today, we'll build our very first pipeable operator: map. This is a cornerstone of functional and reactive programming, and implementing it will be a great exercise in using TypeScript generics to create powerful, type-safe APIs.

Lesson Goal: By the end of this lesson, you will be able to implement and test a type-safe map operator function that correctly infers its output type from the projection function's return type.


1. The Challenge of Type-Safe Transformations

Before we write any code for our observable map, let's solidify our understanding of the core problem it solves, especially regarding types. The map function's job is to take an input value of one type and transform it into an output value of potentially another type. For example, mapping an array of numbers to an array of strings.

How do we tell TypeScript about this relationship between input and output types? Using any would work, but it sacrifices all the benefits of type safety. This is where generics come in.

To see a clear, practical explanation of this, I'd like you to read a few sections from an excellent article on the topic. It uses a standard array map function as its example, which is a perfect parallel for what we're about to do with observables.

The ultimate explanation of TypeScript generics: functions

This article, 'The ultimate explanation of TypeScript generics: functions' from codewithstyle.info, clearly demonstrates why any is insufficient and how generics provide a type-safe solution for functions like map.

Please read the sections 'Motivation: The problem with any', 'Generic functions', and 'Calling generic functions'. Pay close attention to how the type parameters <TElement, TResult> are used to create a relationship between the input array, the mapping function, and the returned array.

The key takeaway is the function signature pattern:
function map<T, R>(items: T[], mappingFunction: (item: T) => R): R[]

Here, T represents the input type and R represents the output type. TypeScript can infer these types, giving us both flexibility and safety. This is precisely the pattern we will adapt for our observable operator.

2. Implementing the map Operator

A pipeable operator in our library is a higher-order function. It takes some configuration (for map, this is the projection function) and returns a new function. This new function then takes the source Observable as input and returns a new, transformed Observable.

The structure looks like this:

map(projectFn) -> (sourceObservable) -> newObservable

Let's implement this. The map operator will:

  1. Return a new Observable.
  2. When this new observable is subscribed to, it will subscribe to the source observable.
  3. For each next value from the source, it will apply the project function.
  4. It will emit the result of the projection to its own observer.
  5. It will pass along any error or complete notifications.
  6. It will ensure that unsubscribing from the output observable also unsubscribes from the source.

Here is the implementation. Create a new file src/operators/map.ts:

import { Observable } from '../core/observable'; // Adjust path if needed

/**
 * Applies a given project function to each value emitted by the source Observable,
 * and emits the resulting values as an Observable.
 *
 * @param project The function to apply to each value emitted by the source Observable.
 * @returns A function that returns an Observable that emits the values from the
 * source Observable transformed by the given project function.
 */
export function map<T, R>(project: (value: T) => R) {
  return (source: Observable<T>): Observable<R> => {
    return new Observable<R>(observer => {
      const sourceSubscription = source.subscribe({
        next: (value) => {
          try {
            const projectedValue = project(value);
            observer.next(projectedValue);
          } catch (err) {
            observer.error(err);
          }
        },
        error: (err) => {
          observer.error(err);
        },
        complete: () => {
          observer.complete();
        },
      });

      // Return the teardown logic. When the output observable is unsubscribed,
      // we unsubscribe from the source.
      return () => {
        sourceSubscription.unsubscribe();
      };
    });
  };
}

Notice the generic signature map<T, R>.

  • T is the type of the value from the source observable. TypeScript will infer this.
  • R is the return type of your project function. TypeScript will also infer this, and it becomes the value type for the Observable<R> that the operator returns.

This structure ensures that if you map(x => x * 2) on an Observable<number>, the result is correctly typed as Observable<number>, and if you map(x => String(x)), the result is Observable<string>.

3. Writing Good Generic Functions

As a lead developer, you're not just interested in making things work, but in making them robust and well-designed. The official TypeScript documentation has some excellent guidelines for writing high-quality generic functions.

TypeScript: Documentation - More on Functions

The official TypeScript documentation provides concise, authoritative examples and best practices for creating generic functions.

Please review the 'Inference' subsection within 'Generic Functions' to see another example of a generic map. Then, read the section 'Guidelines for Writing Good Generic Functions'. The rules about using fewer type parameters and ensuring they relate multiple values are particularly relevant.

Our map implementation follows these guidelines well. The type parameters T and R are not superfluous; they are essential for relating the type of the source observable, the projection function's signature, and the output observable's type.

4. Testing the map Operator

With the implementation in place, let's verify its correctness with unit tests. We'll use Vitest, which you configured in Module 1. Create a new test file src/operators/map.test.ts.

We need to test a few scenarios:

  1. It correctly transforms values.
  2. It correctly infers the output type (the test code itself will fail to compile if this is wrong).
  3. It propagates complete notifications.
  4. It propagates error notifications from the source.
  5. It handles errors thrown by the projection function itself.

Here are the tests you can add:

import { describe, it, expect, vi } from 'vitest';
import { of } from '../creation/of'; // Assuming you have `of` from a previous lesson
import { map } from './map';

describe('map', () => {
  it('should transform values from the source observable', () => {
    const source$ = of(1, 2, 3);
    const mapOperator = map((x: number) => x * 10);
    const result$ = mapOperator(source$);

    const observer = {
      next: vi.fn(),
      error: vi.fn(),
      complete: vi.fn(),
    };

    result$.subscribe(observer);

    expect(observer.next).toHaveBeenCalledTimes(3);
    expect(observer.next).toHaveBeenCalledWith(10);
    expect(observer.next).toHaveBeenCalledWith(20);
    expect(observer.next).toHaveBeenCalledWith(30);
    expect(observer.complete).toHaveBeenCalled();
  });

  it('should transform the type of the values', () => {
    const source$ = of(1, 2, 3);
    // The output type is inferred as Observable<string>
    const result$ = source$.pipe(map((x) => `value: ${x}`));

    const observer = {
      next: vi.fn(),
      error: vi.fn(),
      complete: vi.fn(),
    };

    result$.subscribe(observer);

    expect(observer.next).toHaveBeenCalledWith('value: 1');
    expect(observer.next).toHaveBeenCalledWith('value: 2');
    expect(observer.next).toHaveBeenCalledWith('value: 3');
    // The following line would cause a TypeScript error if inference failed:
    const aString: string = 'test';
    result$.subscribe(val => {
        const anotherString: string = val; // This is type-safe
    });
  });

  it('should handle errors from the projection function', () => {
    const source$ = of(1, 2, 3);
    const mapOperator = map((x: number) => {
      if (x > 2) {
        throw new Error('Too large!');
      }
      return x;
    });
    const result$ = mapOperator(source$);

    const observer = {
      next: vi.fn(),
      error: vi.fn(),
      complete: vi.fn(),
    };

    result$.subscribe(observer);

    expect(observer.next).toHaveBeenCalledTimes(2);
    expect(observer.next).toHaveBeenCalledWith(1);
    expect(observer.next).toHaveBeenCalledWith(2);
    expect(observer.error).toHaveBeenCalledWith(new Error('Too large!'));
    expect(observer.complete).not.toHaveBeenCalled();
  });
});

(Note: I've used source$.pipe(map(...)) in the second test to show how it will be used in practice, assuming your pipe method from Module 3 is available.)

Run your tests (npm test or vitest) to confirm everything works as expected.


Conclusion

Excellent work! You have successfully implemented map, one of the most fundamental operators in reactive programming.

Key Takeaways:

  • Pipeable operators are higher-order functions that take configuration and return a function that transforms an observable.
  • TypeScript generics, specifically using two type parameters like <T, R>, are the key to creating a map operator that is both flexible and fully type-safe.
  • A robust operator must correctly handle the full observable contract: next, error, complete, and the teardown logic for unsubscription.
  • Wrapping the projection function call in a try...catch block is crucial for gracefully handling errors and propagating them through the stream.

Next Lesson Preview:
In the next lesson, we will continue building our operator library by implementing filter. This will reinforce the pattern you learned today and introduce a new TypeScript feature—type predicates—which allow the filter operator to intelligently narrow the type of the output observable.

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

Sign up