Angular 40 🅰️ RxJS Transformation and Filtering Operators
The previous chapter introduced Observables and the operator pipeline. This chapter goes deeper into the two largest categories of operators: transformation and filtering. Transformation operators change the values that flow through the stream — map converts each value, scan accumulates, switchMap and its siblings flatten inner Observables. Filtering operators decide which values continue — filter by predicate, take by count, debounceTime by timing, distinctUntilChanged by comparison. Together they cover the majority of everyday RxJS work, and almost every real pipeline in an Angular application uses several of them. The higher-order mapping operators — switchMap, mergeMap, concatMap, exhaustMap — are where the subtlety concentrates, because they differ in how they handle concurrent inner Observables, and choosing the wrong one produces bugs that are hard to see. This chapter covers each operator, when to reach for it, and the patterns that keep pipelines predictable.
Key point: Transformation operators change values; filtering operators remove them. map is the simplest transformation, scan accumulates state. The higher-order mapping operators (switchMap, mergeMap, concatMap, exhaustMap) transform each outer value into an inner Observable and flatten the result — and they differ in concurrency: switchMap cancels the previous inner, mergeMap runs all in parallel, concatMap queues them, exhaustMap ignores new outer values while an inner is active. The choice among these four is the single most consequential decision in a pipeline. Filtering operators remove values by predicate (filter), count (take, skip), position (first, last), timing (debounceTime, throttleTime, auditTime), or comparison (distinctUntilChanged).
Transformation: map and its relatives
map is the fundamental transformation. It applies a function to each value and emits the result.
import { of } from 'rxjs';
import { map } from 'rxjs/operators';
of(1, 2, 3).pipe(
map((n) => n * 10),
).subscribe((v) => console.log(v));
// 10
// 20
// 30
The function receives each value and returns a new one. The type of the output can differ from the input, which is how a stream of one shape becomes a stream of another. map never filters — every input value produces exactly one output value.
Why map is used constantly. Almost every stream needs to be reshaped — an HTTP response needs to be unwrapped, a form value needs to be transformed, a raw event needs to be converted to a domain object. map is the operator for all of these, and it is the most frequently used operator in the library.
pluck and map with a property name. Before TypeScript’s inference improved, pluck('name') was the idiomatic way to extract a property. Today map((x) => x.name) is preferred because it is type-safe and works with nested access.
scan as the accumulation operator. scan is reduce for streams — it applies an accumulator function to each value and emits the running result.
of(1, 2, 3, 4).pipe(
scan((acc, value) => acc + value, 0),
).subscribe((v) => console.log(v));
// 1
// 3
// 6
// 10
Each value is added to the running total, and the total is emitted after each addition. The second argument to scan is the initial accumulator. Unlike reduce, scan emits every intermediate result, which makes it useful for running totals, counters, and state accumulation.
Why scan is the basis of state management. A stream of actions piped through scan produces a stream of states. This is exactly the Redux pattern, and it is why scan is the operator behind every state management library. The accumulator function is the reducer, and the emitted values are the states.
pairwise for consecutive pairs. pairwise emits each value paired with the previous one, which is useful for detecting changes.
of(1, 2, 3, 4).pipe(
pairwise(),
).subscribe(([prev, curr]) => console.log(`${prev} → ${curr}`));
// 1 → 2
// 2 → 3
// 3 → 4
The first value is emitted only when the second arrives, because a pair requires two values. The operator is useful for detecting direction changes, computing deltas, and comparing consecutive states.
Higher-order mapping: the four flattening operators
When map produces an Observable, the result is an Observable of Observables — a nested structure that is usually not what is wanted. The higher-order mapping operators transform each value into an inner Observable and flatten the result into a single stream. They differ in how they handle the inner Observables when the outer stream emits faster than the inner completes.
switchMap — cancel the previous. Each new outer value cancels the previous inner Observable and subscribes to a new one.
search$.pipe(
debounceTime(300),
switchMap((term) => this.http.get(`/api/search?q=${term}`)),
).subscribe((results) => console.log(results));
When a new search term arrives, the previous HTTP request is canceled if it is still in flight, and a new one is started. Only the most recent request’s result is emitted. This is the correct behavior for search: stale results are discarded.
Why switchMap is the default for reads. Any operation where only the latest result matters — search, route parameter changes, typeahead — should use switchMap. The cancellation prevents out-of-order responses and reduces wasted work.
mergeMap — run in parallel. Each outer value starts an inner Observable, and all of them run concurrently. Results are emitted as they arrive, in whatever order.
ids$.pipe(
mergeMap((id) => this.http.get(`/api/items/${id}`)),
).subscribe((item) => console.log(item));
All the requests are sent at once, and the results arrive as each completes. The order is not guaranteed. This is correct for independent operations where order does not matter, such as loading multiple independent items.
Why mergeMap is the right default when there is no reason to prefer another. It is the most general of the four. It does not cancel, does not queue, and does not ignore. When the outer values are independent and all of them should be processed, mergeMap is correct. The other three are specializations.
concatMap — queue in order. Each outer value is processed after the previous inner Observable completes. The inner Observables run one at a time, in order.
actions$.pipe(
concatMap((action) => this.http.post('/api/save', action)),
).subscribe((response) => console.log(response));
Each save is sent after the previous one completes. The order is preserved, and the requests are serialized. This is the correct behavior for writes that must happen in order, such as saving a sequence of edits.
Why concatMap is the choice for ordered writes. Writes that depend on each other — creating a resource and then updating it, applying a sequence of operations — must be serialized. mergeMap would send them in parallel and produce a race. concatMap sends them one at a time, in the order the outer stream emitted them.
exhaustMap — ignore new values while busy. The first outer value starts an inner Observable. While it is running, new outer values are ignored. When the inner completes, the next outer value can start a new inner.
submit$.pipe(
exhaustMap(() => this.http.post('/api/submit', data)),
).subscribe((response) => console.log(response));
If the user clicks submit while the request is in flight, the click is ignored. The first request completes, and a subsequent click starts a new one. This is the correct behavior for preventing double submission.
Why exhaustMap is the choice for submit buttons. A user who clicks twice should not produce two submissions. exhaustMap ignores the second click while the first is being processed. It is the natural fit for login forms, payment buttons, and any action that must not be duplicated.
Comparison of the four:
| Operator | Behavior | Use |
|---|---|---|
switchMap | Cancels previous | Reads, search, route params |
mergeMap | Parallel | Independent operations |
concatMap | Queues | Ordered writes |
exhaustMap | Ignores while busy | Submit buttons, prevent duplicates |
Why the distinction matters. These four operators handle the same signature — a function returning an Observable — but produce completely different behavior. A mergeMap where concatMap is needed produces a race condition. A switchMap where mergeMap is needed cancels work that should have completed. The choice is not stylistic; it is semantic.
Why
switchMapis the most common and the most misunderstood. It is the correct default for reads, and it is used constantly. But it cancels, and cancellation is sometimes wrong. The rule of thumb: if the operation is a read and only the latest result matters, useswitchMap; if it is a write, useconcatMaporexhaustMap. Reaching forswitchMapby habit on a write is a common bug.
Filtering by predicate and count
The simplest filtering operators decide whether a value continues based on a condition or a count.
filter by predicate. Emits only the values for which the predicate returns true.
of(1, 2, 3, 4, 5).pipe(
filter((n) => n % 2 === 0),
).subscribe((v) => console.log(v));
// 2
// 4
The predicate receives each value and returns a boolean. Values for which it returns false are dropped. The type of the stream can be narrowed with a type guard predicate, which is a powerful TypeScript-integration feature.
type Result = { ok: true; value: string } | { ok: false; error: string };
results$.pipe(
filter((r): r is { ok: true; value: string } => r.ok),
).subscribe((r) => console.log(r.value)); // r is narrowed
The type guard predicate narrows the type of the stream to the success branch, so the downstream code can access value without a check.
take, takeLast, takeWhile. take(n) emits the first n values and completes. takeLast(n) emits the last n values when the source completes. takeWhile(pred) emits values while the predicate is true and completes at the first false.
of(1, 2, 3, 4, 5).pipe(take(3)).subscribe((v) => console.log(v));
// 1
// 2
// 3
of(1, 2, 3, 4, 5).pipe(takeWhile((n) => n < 3)).subscribe((v) => console.log(v));
// 1
// 2
skip, skipLast, skipWhile. The inverses — they drop values instead of taking them.
of(1, 2, 3, 4, 5).pipe(skip(2)).subscribe((v) => console.log(v));
// 3
// 4
// 5
of(1, 2, 3, 4, 5).pipe(skipWhile((n) => n < 3)).subscribe((v) => console.log(v));
// 3
// 4
// 5
first, last, and their defaults. first() emits the first value and completes. last() emits the last value when the source completes. Both accept an optional predicate and a default value.
of(1, 2, 3).pipe(first()).subscribe((v) => console.log(v)); // 1
of(1, 2, 3).pipe(last()).subscribe((v) => console.log(v)); // 3
The first operator is equivalent to take(1) but throws an error if the source completes without emitting, unless a default is provided. take(1) completes silently. The distinction matters when the source might be empty.
single for exactly-one. single() emits the single value if the source emits exactly one, and errors otherwise. It is useful for validation.
Filtering by timing
Timing operators are the ones that handle rapid input — keystrokes, scroll events, resize events. They reduce the frequency of values by waiting for a condition.
debounceTime(ms) — wait for silence. Emits a value only after the given number of milliseconds have passed without another value.
search$.pipe(
debounceTime(300),
).subscribe((term) => console.log(term));
If the user types five characters in 200 milliseconds, only the last one is emitted, 300 milliseconds after the typing stops. This is the standard operator for search boxes, because it waits for the user to finish typing before sending a request.
throttleTime(ms) — emit at most once per period. Emits the first value, then ignores subsequent values for the given period.
scroll$.pipe(
throttleTime(100),
).subscribe(() => console.log('scrolled'));
The first scroll event is emitted, and events in the next 100 milliseconds are ignored. This is the correct behavior for scroll handlers, which fire very rapidly and usually only need to be handled once per frame or per interval.
auditTime(ms) — emit the last value after a period. Waits for the given period, then emits the most recent value.
input$.pipe(
auditTime(500),
).subscribe((value) => console.log(value));
The difference from throttleTime is which value is emitted: throttleTime emits the first, auditTime emits the last. For input where the latest value matters, auditTime is the right choice.
sample(notifier$) — emit the latest when the notifier emits. Takes the latest value from the source whenever the notifier emits.
source$.pipe(
sample(interval(1000)),
).subscribe((v) => console.log(v));
The source emits values continuously, and sample takes the most recent one every second. This is useful for polling the latest state of something that changes rapidly.
Why the timing operators are used together with mapping operators. A search pipeline typically combines debounceTime (to wait for typing to stop), distinctUntilChanged (to skip repeat searches), and switchMap (to cancel the previous request). The combination is the standard search pattern.
Why debounceTime is not the same as delay. delay shifts the entire stream by a fixed time — every value is emitted later. debounceTime drops values that are followed too quickly by another. The two have different purposes, and debounceTime is the one for reducing rapid input.
Filtering by comparison
distinctUntilChanged is the operator for skipping consecutive duplicate values. It compares each value with the previous one and emits only when they differ.
of(1, 1, 2, 2, 3, 1).pipe(
distinctUntilChanged(),
).subscribe((v) => console.log(v));
// 1
// 2
// 3
// 1
The consecutive duplicates are dropped; the non-consecutive 1 at the end is emitted because it differs from the previous 3. The operator is essential in search pipelines, where the same term can be typed twice and should not produce two requests.
Why the default comparison is ===. For primitives, === works. For objects, it compares references, which means two objects with the same contents are considered different. This is often not what is wanted, and the operator accepts a custom comparator as its argument.
source$.pipe(
distinctUntilChanged((prev, curr) => prev.id === curr.id),
)
The custom comparator compares by id, so objects with the same id are considered equal even if other fields differ. This is the standard pattern for streams of domain objects.
distinct for the full stream. distinct emits only values that have never been emitted before, not just consecutive ones. It maintains a set of all previous values, which grows unbounded for an infinite stream.
of(1, 2, 1, 3, 2).pipe(
distinct(),
).subscribe((v) => console.log(v));
// 1
// 2
// 3
distinct is rarely used on infinite streams because of the memory growth. It is useful on finite streams where the set is bounded.
Why distinctUntilChanged should come before switchMap. The order matters. debounceTime then distinctUntilChanged then switchMap means: wait for typing to stop, skip if the term is the same as last time, then send the request. Putting distinctUntilChanged after switchMap would not skip the request, because the request has already been sent. The order is: reduce the input as much as possible before triggering the side effect.
Combining operators for real pipelines
The operators are designed to compose, and a real pipeline often combines several. The search box is the canonical example.
@Component({ /* ... */ })
export class SearchComponent {
private readonly http = inject(HttpClient);
private readonly destroyRef = inject(DestroyRef);
readonly searchControl = new FormControl('', { nonNullable: true });
readonly results$ = this.searchControl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
filter((term) => term.length >= 2),
switchMap((term) => this.http.get<Result[]>(`/api/search?q=${term}`)),
catchError(() => of([])),
takeUntilDestroyed(this.destroyRef),
);
}
Reading the pipeline top to bottom: valueChanges emits on every keystroke. debounceTime(300) waits for the typing to pause. distinctUntilChanged() skips if the term is unchanged. filter drops terms shorter than two characters. switchMap cancels the previous request and starts a new one. catchError recovers from HTTP errors. takeUntilDestroyed cleans up when the component is destroyed.
Why the order is correct. Each operator reduces the work for the next. debounceTime reduces the number of values. distinctUntilChanged removes duplicates. filter removes short terms. switchMap cancels superseded requests. The order minimizes the number of HTTP calls, which is the expensive operation.
Why catchError comes after switchMap. The error to catch is an HTTP error from the inner Observable. Placing catchError after switchMap catches those errors. Placing it before would only catch errors from the outer stream, which does not produce them.
Why takeUntilDestroyed must be last. The operator completes the entire pipeline when the component is destroyed. It has to be at the end so that it observes the completion of everything upstream.
Why the pipeline is readable. Each line does one thing, and the sequence describes the intent. This is the style that RxJS is designed for — the operators are the vocabulary, and the pipeline is the sentence.
Complete Example Session
import { Component, DestroyRef, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { of, Subject } from 'rxjs';
import {
map, scan, pairwise, filter, take, takeWhile, skip, first, last,
debounceTime, throttleTime, auditTime, distinctUntilChanged,
switchMap, mergeMap, concatMap, exhaustMap,
catchError, takeUntilDestroyed,
} from 'rxjs/operators';
// ============================================
// PART 1: MAP
// ============================================
of(1, 2, 3).pipe(map((n) => n * 10)).subscribe((v) => console.log(v));
// 10, 20, 30
// ============================================
// PART 2: SCAN
// ============================================
of(1, 2, 3, 4).pipe(scan((acc, n) => acc + n, 0)).subscribe((v) => console.log(v));
// 1, 3, 6, 10
// ============================================
// PART 3: PAIRWISE
// ============================================
of(1, 2, 3, 4).pipe(pairwise()).subscribe(([a, b]) => console.log(a, '→', b));
// 1 → 2, 2 → 3, 3 → 4
// ============================================
// PART 4: FILTER WITH TYPE GUARD
// ============================================
type Result = { ok: true; value: string } | { ok: false; error: string };
const results$ = new Subject<Result>();
results$.pipe(
filter((r): r is { ok: true; value: string } => r.ok),
).subscribe((r) => console.log(r.value)); // r is narrowed
// ============================================
// PART 5: TAKE, SKIP, FIRST, LAST
// ============================================
of(1, 2, 3, 4, 5).pipe(take(2)).subscribe((v) => console.log(v));
// 1, 2
of(1, 2, 3, 4, 5).pipe(skip(3)).subscribe((v) => console.log(v));
// 4, 5
of(1, 2, 3).pipe(first()).subscribe((v) => console.log(v)); // 1
of(1, 2, 3).pipe(last()).subscribe((v) => console.log(v)); // 3
// ============================================
// PART 6: TIMING OPERATORS
// ============================================
const input$ = new Subject<string>();
input$.pipe(debounceTime(300)).subscribe((v) => console.log('debounced:', v));
input$.pipe(throttleTime(500)).subscribe((v) => console.log('throttled:', v));
input$.pipe(auditTime(500)).subscribe((v) => console.log('audited:', v));
// ============================================
// PART 7: DISTINCT UNTIL CHANGED
// ============================================
of(1, 1, 2, 2, 3, 1).pipe(
distinctUntilChanged(),
).subscribe((v) => console.log(v));
// 1, 2, 3, 1
// Custom comparator
of({ id: 1 }, { id: 1 }, { id: 2 }).pipe(
distinctUntilChanged((a, b) => a.id === b.id),
).subscribe((v) => console.log(v.id));
// 1, 2
// ============================================
// PART 8: THE FOUR FLATTENING OPERATORS
// ============================================
const clicks$ = new Subject<void>();
// switchMap — cancels previous
clicks$.pipe(switchMap(() => this.http.get('/api/latest'))).subscribe();
// mergeMap — parallel
clicks$.pipe(mergeMap(() => this.http.get('/api/items'))).subscribe();
// concatMap — queued
clicks$.pipe(concatMap(() => this.http.post('/api/save', {}))).subscribe();
// exhaustMap — ignores while busy
clicks$.pipe(exhaustMap(() => this.http.post('/api/submit', {}))).subscribe();
// ============================================
// PART 9: SEARCH PIPELINE
// ============================================
@Component({
selector: 'app-search',
standalone: true,
imports: [ReactiveFormsModule],
template: `<input [formControl]="search" placeholder="Search" />`,
})
export class SearchComponent {
private readonly http = inject(HttpClient);
private readonly destroyRef = inject(DestroyRef);
readonly search = new FormControl('', { nonNullable: true });
readonly results$ = this.search.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
filter((term) => term.length >= 2),
switchMap((term) => this.http.get<Result[]>(`/api/search?q=${term}`)),
catchError(() => of([])),
takeUntilDestroyed(this.destroyRef),
);
}
// ============================================
// PART 10: RUNNING TOTAL
// ============================================
const actions$ = new Subject<{ type: 'add' | 'reset'; value?: number }>();
const total$ = actions$.pipe(
scan((state, action) => {
if (action.type === 'reset') return 0;
return state + (action.value ?? 0);
}, 0),
);
total$.subscribe((total) => console.log('total:', total));
The ten parts cover transformation, accumulation, pairing, filtering with type guards, count-based filtering, timing operators, distinct, the four flattening operators, the search pipeline, and the scan-based state accumulation.
Quick Reference
Transformation Operators
| Operator | Purpose |
|---|---|
map | Transform each value |
scan | Accumulate with running result |
pairwise | Consecutive pairs |
pluck | Extract a property (legacy) |
toArray | Collect all values into an array |
Flattening Operators
| Operator | Concurrency | Use |
|---|---|---|
switchMap | Cancel previous | Reads, search, route params |
mergeMap | Parallel | Independent operations |
concatMap | Queued | Ordered writes |
exhaustMap | Ignore while busy | Submit buttons |
Filtering by Predicate
| Operator | Purpose |
|---|---|
filter | Keep matching values |
takeWhile | Take while predicate true |
skipWhile | Skip while predicate true |
Filtering by Count
| Operator | Purpose |
|---|---|
take(n) | First n |
takeLast(n) | Last n |
skip(n) | Drop first n |
skipLast(n) | Drop last n |
first() | First value |
last() | Last value |
single() | Exactly one |
Filtering by Timing
| Operator | Emits |
|---|---|
debounceTime(ms) | Last value after silence |
throttleTime(ms) | First value per period |
auditTime(ms) | Last value after period |
sample(notifier$) | Latest when notifier emits |
Filtering by Comparison
| Operator | Purpose |
|---|---|
distinctUntilChanged | Skip consecutive duplicates |
distinct | Skip all duplicates (memory) |
Best Practices
✅ Do This:
// Use switchMap for search
search$.pipe(debounceTime(300), distinctUntilChanged(), switchMap((q) => http.get(...))) // ✅
// Use concatMap for ordered writes
actions$.pipe(concatMap((a) => http.post('/save', a))) // ✅
// Use exhaustMap for submit buttons
submit$.pipe(exhaustMap(() => http.post('/submit', data))) // ✅
// Use type guard in filter
filter((r): r is Success => r.ok) // ✅
// Use custom comparator for object streams
distinctUntilChanged((a, b) => a.id === b.id) // ✅
// Order operators to minimize work
debounceTime → distinctUntilChanged → filter → switchMap // ✅
// Use scan for accumulated state
actions$.pipe(scan(reducer, initialState)) // ✅
❌ Don’t Do This:
// Don't use switchMap for writes
save$.pipe(switchMap((v) => http.post('/save', v))); // cancels // ⚠️
// Don't use mergeMap for ordered operations
actions$.pipe(mergeMap((a) => http.post('/save', a))); // race // ⚠️
// Don't use exhaustMap for reads
params$.pipe(exhaustMap((p) => http.get(`/api/${p}`))); // ignores // ⚠️
// Don't use distinct on infinite streams
source$.pipe(distinct()); // memory grows // ⚠️
// Don't put distinctUntilChanged after switchMap
.pipe(switchMap(...), distinctUntilChanged()) // request sent // ⚠️
// Don't use take(0)
.pipe(take(0)) // completes immediately // ⚠️
// Don't use filter without a type guard when narrowing is needed
filter((r) => r.ok) // type not narrowed // ⚠️
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
switchMap on writes | Cancels a save | Use concatMap |
mergeMap on ordered writes | Race condition | Use concatMap |
exhaustMap on reads | Ignores parameters | Use switchMap |
distinct on infinite stream | Unbounded memory | Use distinctUntilChanged |
| Wrong operator order | Wasted requests | Filter before map |
filter without type guard | No narrowing | Use a type predicate |
debounceTime without distinctUntilChanged | Duplicate requests | Add it |
catchError before switchMap | Misses inner errors | Place after |
take(0) | Nothing emitted | Use take(1) |
first() on empty source | Error thrown | Provide a default |
Real-World Examples
1. Search with debounce
search.valueChanges.pipe(debounceTime(300), distinctUntilChanged(), switchMap(search))
2. Ordered saves
edits$.pipe(concatMap((e) => this.http.post('/save', e)))
3. Double-submit prevention
submit$.pipe(exhaustMap(() => this.http.post('/submit', data)))
4. Load multiple items
ids$.pipe(mergeMap((id) => this.http.get(`/items/${id}`)))
5. Running total
actions$.pipe(scan((sum, a) => sum + a.value, 0))
6. Skip unchanged form values
form.valueChanges.pipe(distinctUntilChanged())
7. Throttle scroll events
fromEvent(window, 'scroll').pipe(throttleTime(100))
8. Take the first value
source$.pipe(first())
9. Type-guard filter
filter((r): r is Success => r.ok)
10. Pairwise delta
values$.pipe(pairwise(), map(([a, b]) => b - a))
Visual: The Four Flattening Operators
┌──────────────────────────────────────────────────────────┐
│ OUTER: ──a──────b──────c────► │
│ │
│ switchMap │
│ a ──► A │
│ b ──► A canceled, B │
│ c ──► B canceled, C │
│ emits: C │
│ │
│ mergeMap │
│ a ──► A │
│ b ──► B │
│ c ──► C │
│ emits: A, B, C in completion order │
│ │
│ concatMap │
│ a ──► A │
│ b ──► queued │
│ c ──► queued │
│ emits: A, B, C in order │
│ │
│ exhaustMap │
│ a ──► A │
│ b ──► ignored │
│ c ──► ignored │
│ emits: A │
│ │
└──────────────────────────────────────────────────────────┘
Visual: Timing Operators
┌──────────────────────────────────────────────────────────┐
│ SOURCE: ──a─b─c─────d─e─────f──► │
│ │
│ debounceTime(3) │
│ a,b,c arrive quickly → c emitted after silence │
│ d,e arrive quickly → e emitted after silence │
│ f → f emitted │
│ emits: c, e, f │
│ │
│ throttleTime(3) │
│ a emitted, b,c ignored │
│ d emitted, e ignored │
│ f emitted │
│ emits: a, d, f │
│ │
│ auditTime(3) │
│ wait 3, emit latest → c │
│ wait 3, emit latest → e │
│ emits: c, e, f │
│ │
└──────────────────────────────────────────────────────────┘
Visual: The Search Pipeline
┌──────────────────────────────────────────────────────────┐
│ valueChanges │
│ │ "a", "ab", "abc", "abcd" │
│ ▼ │
│ debounceTime(300) │
│ │ waits for typing to stop │
│ │ emits: "abcd" │
│ ▼ │
│ distinctUntilChanged() │
│ │ skips if same as last │
│ ▼ │
│ filter(term => term.length >= 2) │
│ │ drops short terms │
│ ▼ │
│ switchMap(term => http.get(...)) │
│ │ cancels previous request │
│ ▼ │
│ catchError(() => of([])) │
│ │ recovers from errors │
│ ▼ │
│ takeUntilDestroyed(destroyRef) │
│ │ cleans up on destroy │
│ ▼ │
│ results │
│ │
└──────────────────────────────────────────────────────────┘
Visual: scan for State
┌──────────────────────────────────────────────────────────┐
│ actions: ──add(1)──add(2)──add(3)──reset──add(5)──► │
│ │
│ scan((state, action) => { │
│ if (action.type === 'reset') return 0; │
│ return state + action.value; │
│ }, 0) │
│ │
│ emits: 1 3 6 0 5 │
│ │
│ Each emitted value is the state after that action. │
│ This is the basis of Redux-style state management. │
│ │
└──────────────────────────────────────────────────────────┘
Visual: Operator Choice Decision
┌──────────────────────────────────────────────────────────┐
│ Is the operation a read or a write? │
│ │ │
│ ├── Read (only latest matters) │
│ │ └── switchMap │
│ │ │
│ ├── Write, must be ordered │
│ │ └── concatMap │
│ │ │
│ ├── Write, must not duplicate │
│ │ └── exhaustMap │
│ │ │
│ └── Independent, order irrelevant │
│ └── mergeMap │
│ │
│ When in doubt about a read, switchMap. │
│ When in doubt about a write, concatMap or exhaustMap. │
│ │
└──────────────────────────────────────────────────────────┘
Summary
| Category | Key Operators |
|---|---|
| Transformation | map, scan, pairwise |
| Flattening | switchMap, mergeMap, concatMap, exhaustMap |
| Predicate filter | filter, takeWhile, skipWhile |
| Count filter | take, skip, first, last |
| Timing filter | debounceTime, throttleTime, auditTime |
| Comparison filter | distinctUntilChanged, distinct |
| Error | catchError, retry |
| Cleanup | takeUntilDestroyed, finalize |
Key takeaways:
maptransforms,scanaccumulates — the two fundamental transformation operators, andscanis the basis of state management- The four flattening operators differ in concurrency —
switchMapcancels,mergeMapparallelizes,concatMapqueues,exhaustMapignores while busy - The flattening choice is semantic, not stylistic —
switchMapfor reads,concatMapfor ordered writes,exhaustMapfor submit prevention,mergeMapfor independent parallel work filterwith a type guard narrows the stream type — this is a powerful TypeScript integration that removes the need for downstream checks- Timing operators reduce rapid input —
debounceTimewaits for silence,throttleTimeemits the first per period,auditTimeemits the last per period distinctUntilChangedcompares consecutive values — the default is===, and a custom comparator is needed for objects- The order of operators matters — filtering before mapping minimizes work, and
distinctUntilChangedmust come beforeswitchMapto skip requests - The search pipeline is the canonical composition —
debounceTime,distinctUntilChanged,filter,switchMap,catchError,takeUntilDestroyed catchErrorafterswitchMapcatches inner errors — placing it before only catches errors from the outer stream- Choosing the wrong flattening operator is the most common serious RxJS bug — the fix is to identify whether the operation is a read or a write, and whether order or duplication matters
Remember: Transformation and filtering operators are the vocabulary of RxJS pipelines. map and scan reshape values; filter and its relatives remove them; the four flattening operators decide how inner Observables are handled. The search pipeline — debounce, distinct, filter, switchMap, catchError, takeUntilDestroyed — is the pattern that appears in almost every Angular application, and it demonstrates all the categories at once. Master the operators, and the pipelines become readable; master the flattening choice, and the pipelines become correct.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!