In RxJS, what is the difference between the map and tap operators, and why do side effects belong in tap?
answer
- one returns a new value
- the other ignores its return value
- passes the same value through
- subscribe, unsubscribe and finalize handlers
basics
~20 smap replaces each value with its projection's result; tap runs a callback for each notification and passes the original value through, ignoring the callback's return. Side effects in tap keep map pure and visible in the pipe.
solid answer
~40 s`map(project)` calls `project(value, index)` for every value and emits the **result**, so it changes what flows downstream. `tap(...)` calls your callback and then emits the **same** value, discarding whatever the callback returns - it exists for side effects such as logging, analytics or setting a loading flag. `tap` also accepts an observer object with `next`, `error` and `complete`, and since RxJS 7.3 `subscribe`, `unsubscribe` and `finalize` handlers. Keeping effects in `tap` means `map` stays a pure transformation that is safe to reorder or test, and a reader can see at a glance where the pipe touches the outside world. Neither operator runs until something subscribes, and an exception thrown in either becomes an `error` notification downstream.
code
ts · 20 linesimport { of, map, tap } from 'rxjs';
const doubled$ = of(10, 20).pipe(
tap({
subscribe: () => console.log('subscribed'),
next: (v) => console.log('raw', v),
finalize: () => console.log('finalized'),
}),
map((v) => v * 2),
);
// nothing is logged yet: no subscriber
doubled$.subscribe((v) => console.log('doubled', v));
// subscribed
// raw 10
// doubled 20
// raw 20
// doubled 40
// finalizedgo deeper
Recall that map emits a transformed value while tap runs a side effect and passes the original value through unchanged.
Explain tap's observer form, including error, complete and the 7.3 subscribe, unsubscribe and finalize handlers, and why a map without a return emits undefined.
Use tap for lifecycle effects such as loading flags via finalize, and spot effects hidden in map that break when operators are reordered or pipes are shared.
Set a team convention that transformations stay pure and every external effect is visible in tap or in the subscriber, so pipes can be reviewed and tested predictably.
## Two operators, two purposes RxJS pipeable operators are functions passed to `.pipe()`. Two of the most common look similar - both take a callback that runs for every value - but they do different jobs. | | `map(project)` | `tap(observerOrNext)` | |---|---|---| | Emits | the value returned by `project` | the original value, unchanged | | Callback's return value | becomes the new value | ignored | | Callback arguments | `(value, index)` | `(value)` for `next` | | Sees `error` / `complete` | no | yes, with an observer object | | Intended for | pure transformation | side effects | ## map: transform `map(project)` emits `project(value, index)` for each source value, where `index` counts from `0` per subscription. Errors and completion pass straight through. If `project` throws, RxJS catches the exception and sends it downstream as an `error` notification, ending the stream. A frequent bug is using `map` for an effect and forgetting to return: `map(v => { console.log(v); })` emits `undefined` for every value. The TypeScript type reveals it - the stream becomes `Observable<void>` - but only if someone reads it. The old `mapTo(value)` operator is deprecated in RxJS 7 and marked for removal in v9; write `map(() => value)` instead. ## tap: observe without changing `tap` accepts either a single function, used as `next`, or a **`TapObserver`** object: - `next(value)` - for each value; - `error(err)` - when the source errors, before the error continues downstream; - `complete()` - when the source completes; - `subscribe()` - when the tapped stream is subscribed (added in RxJS 7.3); - `unsubscribe()` - when the consumer unsubscribes before the source ended (7.3); - `finalize()` - on any ending: error, complete or unsubscribe (7.3). Whatever the callbacks return is discarded, and the original notification continues. Passing separate `next, error, complete` function arguments, as in `tap(fn, errFn)`, still works but is deprecated in favour of the observer object. ## Why effects go in tap 1. **Readability.** A reviewer scanning a pipe can find every place it touches the outside world by looking for `tap`. 2. **Correctness of `map`.** A `map` that also writes state is easy to break by reordering operators or by copying it into another pipe. 3. **Coverage of all notifications.** Effects that must react to errors or completion - hiding a spinner, recording a failure - need `tap`'s observer form; `map` never sees those. 4. **No accidental value change.** Because `tap` ignores its return value, an effect cannot silently turn the stream into `undefined`. ## Things tap does not do - It does **not** subscribe. A pipe ending in `tap(console.log)` logs nothing until someone subscribes; this is a common "why is my tap not firing" question. - It does **not** run once per stream when there are several subscribers. Each subscription runs the whole pipe, so the effect runs once per subscriber. - It is **not** error handling. An exception thrown inside `tap` becomes an error notification like any other; recovering from errors is done with RxJS's error operators, a separate topic. ## A practical pattern A loading indicator is a typical `tap` use: set `loading = true` in the `subscribe` handler, and set it back to `false` in `finalize`, which fires whether the request completes, errors or is cancelled. Before 7.3 the same effect needed a separate `finalize` operator; both approaches are valid in RxJS 7.8. ## Placement inside the pipe Where `tap` sits decides what it sees. In `source$.pipe(tap(log), map(f))` it logs raw values; in `source$.pipe(map(f), tap(log))` it logs mapped values. For debugging, a tap at each stage shows how a value changes as it moves through the pipe, and a small named helper - a function returning `tap` with a label - keeps those probes easy to find and remove. Removing a `tap` never changes the values downstream, which is exactly why it is safe to add one temporarily. ## What interviewers want to hear "`map` changes the value, `tap` looks at it." Then: the return value of `tap` is ignored, `tap` can observe errors and completion, it does not subscribe on its own, and side effects belong there so that transformations stay pure.
- In RxJS, what does source$.pipe(map(v => { console.log(v); })) emit?It emits `undefined` for every source value, because the arrow function has a block body with no `return`, and `map` emits whatever the projection returns. The logging still happens. Either move the logging into `tap(v => console.log(v))` or return the value explicitly; TypeScript shows the problem as an `Observable<void>`.
- In RxJS 7.3 and later, how does tap's finalize handler differ from its complete handler?`complete` runs only when the source completes. `finalize` runs on every ending - completion, error, or the consumer unsubscribing - after the subscription has closed. That makes `finalize` the right place for cleanup such as clearing a loading flag, where `complete` would miss both cancellation and failure.
map is a workstation on an assembly line that swaps each part for a finished one; tap is an inspector with a clipboard who looks at each part, writes something down, and puts the same part back on the belt.
saying these in an interview costs you the question
- tap can change the value by returning a new one.
- A pipe with tap(console.log) logs values even if nothing subscribes.
- map and tap are interchangeable as long as you return the value.
- tap only sees next values, never errors or completion.
- A tap in a pipe runs once no matter how many subscribers there are.