Hello! Welcome back to our course on developing a modern JavaScript framework.
In our last lesson, you successfully implemented the map operator. This was a significant step, as it established the core pattern for creating pipeable operators: higher-order functions that accept configuration and return a new function to transform an observable stream. You also saw how TypeScript generics are crucial for maintaining type safety across these transformations.
Today, we'll build on that foundation by implementing the filter operator. While map transforms every value, filter conditionally allows values to pass through. The most interesting part of this lesson will be learning how to make our filter operator "smart" enough to inform TypeScript that it has narrowed the type of the values in the stream, a powerful feature enabled by type predicates.
Lesson Goal: By the end of this lesson, you will be able to implement and test a type-safe filter operator function, using a type predicate in the filter function to narrow the output observable's type.
1. The Challenge: Type Narrowing in Streams
Imagine you have an observable that emits values of type string | null. If you use filter to remove all the null values, you, as the developer, know that the output stream will only ever contain strings. But how do we make the TypeScript compiler understand this?
By default, it doesn't. It will still think the output is Observable<string | null>, forcing you to do extra checks downstream.
// The problem:
const source$: Observable<string | null> = of('hello', null, 'world');
const filtered$ = source$.pipe(
filter(value => value !== null)
);
// TypeScript would still think `val` could be `null` here without a type predicate.
filtered$.subscribe(val => console.log(val.toUpperCase())); // Error: 'val' is possibly 'null'.
This is the core problem we need to solve. The solution lies in a TypeScript feature called type predicates.
To get a solid grasp of what type predicates are and how they work, let's watch a video that explains the concept in the context of a standard array filter.
The video 'Type Narrowing in TypeScript' by Andrew Burgess provides an excellent explanation of various narrowing techniques. We'll focus on the section that introduces type predicates and demonstrates their use with arrays, which is directly applicable to our observable filter.
Please watch the section 'Type Predicates for Filtering Arrays' (from 04:47 to 08:19). Pay close attention to the special return type syntax user is StandardUser and how it enables the compiler to infer a more specific type for the filtered array.
As you saw, a function with a return type like pet is Fish is a type guard. When used inside a method like filter, it tells the compiler that if the function returns true, the value being checked is guaranteed to be of the specified type. This is exactly what we need for our filter operator.
2. Designing a Type-Safe filter Operator
To create a truly robust filter operator, we need to support two scenarios:
- A simple predicate that returns
boolean. - A type guard predicate that narrows the type (e.g.,
(value: T) => value is S).
The best way to handle this in TypeScript is with function overloads. We'll define two signatures for our filter function: one for the type guard case and one for the standard boolean case. This gives our users maximum flexibility and type safety.
The article "Filtering Types with Correct Type Inference in RxJs" discusses this exact problem and solution in the context of RxJS. The patterns are directly transferable to our library.
Filtering Types with Correct Type Inference in RxJs
This article by John Crowson clearly lays out the problem and presents two solutions. We'll focus on the superior approach using type guards.
Please read the sections 'User-Defined Type Guards', 'Option 2: Define a Type Guard', and the 'Solution' for filtering null/undefined. Notice how a simple function input is Scroll becomes a powerful tool when passed to filter.
The key takeaway is that by providing a function that acts as a type guard to filter, the operator can correctly infer the narrowed output type, avoiding the need for manual casting with map.
3. Implementation with Overloads
Now, let's implement our filter operator. The internal logic is similar to map, but instead of transforming the value, we'll use the predicate to decide whether to emit it. The "magic" is all in the overloaded function signatures.
Create a new file at src/operators/filter.ts:
import { Observable } from '../core/observable';
// Overload for when a type guard is used, narrowing the type from T to S
export function filter<T, S extends T>(
predicate: (value: T) => value is S
): (source: Observable<T>) => Observable<S>;
// Overload for a standard boolean predicate, the type remains T
export function filter<T>(
predicate: (value: T) => boolean
): (source: Observable<T>) => Observable<T>;
/**
* Filters items emitted by the source Observable by only emitting those that satisfy a specified predicate.
*
* @param predicate A function that evaluates each value emitted by the source Observable.
* If it returns true, the value is emitted. If a type guard is provided, the output
* observable will be of that narrowed type.
* @returns A function that returns an Observable that emits only the values from the source
* that satisfy the predicate.
*/
export function filter<T>(
predicate: (value: T) => boolean
): (source: Observable<T>) => Observable<T> {
return (source: Observable<T>): Observable<T> => {
return new Observable<T>(observer => {
const sourceSubscription = source.subscribe({
next: (value) => {
try {
// Only emit the value if the predicate returns true
if (predicate(value)) {
observer.next(value);
}
} catch (err) {
// If the predicate itself throws an error, propagate it
observer.error(err);
}
},
error: (err) => {
observer.error(err);
},
complete: () => {
observer.complete();
},
});
// Ensure unsubscription is propagated to the source
return () => {
sourceSubscription.unsubscribe();
};
});
};
}
Notice how the implementation itself only needs to care about the (value: T) => boolean signature. The overloads provide the more specific type information to the TypeScript compiler before it even looks at the implementation code.
This image perfectly illustrates the effect of a type guard within a reactive stream:

4. A Note on truthy vs. nullish Filtering
A common mistake is to use a "truthiness" check like filter(Boolean) or filter(value => !!value) to remove null and undefined. While this works for objects, it can have unintended consequences with primitive types like numbers, where 0 is falsy and would be incorrectly filtered out.
The recommended approach is to be explicit with a nullish check: filter(value => value != null). This checks for both null and undefined without affecting other falsy values.
The following video, which discusses a very recent TypeScript feature (inferred type predicates), contains an excellent segment on this exact topic.
Inferred Type Predicates - TypeScript 5.5's top new feature
This video from Michigan TypeScript discusses a new feature in TypeScript 5.5. While the main topic is advanced, the discussion around filtering provides valuable, practical advice that reinforces what we've learned.
Please watch the segment 'Truthiness vs. Nullish Checks in filter' (from 58:02 to 01:02:17). This will clarify why x != null is a more robust predicate for filtering than a simple truthiness check.
5. Testing the filter Operator
Finally, let's write tests to ensure our filter operator works correctly. We need to test not only the filtering logic but also the type-narrowing behavior.
Create a new test file at src/operators/filter.test.ts:
import { describe, it, expect, vi } from 'vitest';
import { of } from '../creation/of';
import { filter } from './filter';
import { Observable } from '../core/observable';
describe('filter', () => {
it('should filter values based on a boolean predicate', () => {
const source$ = of(1, 2, 3, 4, 5);
const observer = { next: vi.fn(), error: vi.fn(), complete: vi.fn() };
source$.pipe(filter((x) => x % 2 === 0)).subscribe(observer);
expect(observer.next).toHaveBeenCalledTimes(2);
expect(observer.next).toHaveBeenCalledWith(2);
expect(observer.next).toHaveBeenCalledWith(4);
expect(observer.error).not.toHaveBeenCalled();
expect(observer.complete).toHaveBeenCalled();
});
it('should narrow the type with a type guard predicate', () => {
const source$ = of('one', null, 'three', undefined, 'five');
const observer = { next: vi.fn(), error: vi.fn(), complete: vi.fn() };
// This is our type guard
const isString = (x: string | null | undefined): x is string => x != null;
const result$ = source$.pipe(filter(isString));
// If type narrowing works, `result$` is Observable<string>, not Observable<string | null | undefined>
result$.subscribe(observer);
// This line would fail to compile if `result$` was not narrowed to Observable<string>
const test: Observable<string> = result$;
expect(observer.next).toHaveBeenCalledTimes(3);
expect(observer.next).toHaveBeenCalledWith('one');
expect(observer.next).toHaveBeenCalledWith('three');
expect(observer.next).toHaveBeenCalledWith('five');
expect(observer.complete).toHaveBeenCalled();
});
it('should propagate errors from the source', () => {
const error = new Error('Source Error');
const source$ = new Observable(observer => {
observer.error(error);
});
const observer = { next: vi.fn(), error: vi.fn(), complete: vi.fn() };
source$.pipe(filter(() => true)).subscribe(observer);
expect(observer.error).toHaveBeenCalledWith(error);
});
it('should handle errors thrown by the predicate function', () => {
const error = new Error('Predicate Error');
const source$ = of(1, 2, 3);
const observer = { next: vi.fn(), error: vi.fn(), complete: vi.fn() };
source$
.pipe(
filter((x) => {
if (x > 2) {
throw error;
}
return true;
})
)
.subscribe(observer);
expect(observer.next).toHaveBeenCalledTimes(2);
expect(observer.next).toHaveBeenCalledWith(1);
expect(observer.next).toHaveBeenCalledWith(2);
expect(observer.error).toHaveBeenCalledWith(error);
expect(observer.complete).not.toHaveBeenCalled();
});
});
Run your tests (npm test or vitest) to confirm that all scenarios pass. The most important test is the one for type narrowing; the fact that const test: Observable<string> = result$; compiles is proof that our overloads are working correctly.
Conclusion
Fantastic work today! You've added another essential operator to your reactive library and tackled a sophisticated TypeScript feature in the process.
Key Takeaways:
- The
filteroperator follows the same structural pattern asmapbut uses a predicate to conditionally emit values. - Type predicates (functions with a return type like
value is Type) are the key to enabling type narrowing in functions likefilter. - Function overloads are a powerful TypeScript pattern for creating a single function that can have different, more specific type signatures for different use cases.
- For filtering out
nullorundefined, a nullish check (value != null) is more robust than a truthiness check (Boolean(value)).
Next Lesson Preview:
In the next lesson, we will implement the scan operator. This will be our first foray into stateful operators—operators that maintain an internal state or "memory" of past values to compute the next one. This will introduce the concept of an accumulator and build upon the generic patterns you've mastered.
Can't find a good explanation? Sign up and we'll make it for you
Sign up