through2, a function wrapper that removes the Transform subclassing boilerplate
Tiny wrapper around Node streams2 Transform to avoid explicit subclassing noise
At a glance
- What is it?
- through2 is a small JavaScript utility for Node.js and Web Streams pipelines that takes a function instead of a Transform subclass, so a stage in a stream is one line instead of a class. The current major version also adds an async generator style, a separate web entry point and a factory function that is not really a constructor.
- Who is it for?
- through2 suits a codebase that builds stream pipelines and wants the stages to read as functions rather than classes, and it suits a project that is moving from Node streams to Web Streams, because the two entry points target the two stream APIs with the same mental model. It is the wrong choice if you need the full Transform surface, since a function wrapper gives you the transform step and not the rest of the stream lifecycle.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 5 days ago.
- What is it written in?
- Mainly JavaScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Star history
- rvagg/through21,907 ★ · +1
| Repository | 2026-10-16 | 2026-10-23 |
|---|---|---|
| rvagg/through2 | 1.9k | 1.9k |
1 Stars in the last 7 days, measured from the daily GitHub snapshot. Compare with other projects
Editorial analysis
The problem it removes, in one before and after
The Why section of the README puts the problem precisely. Writing a Transform stream usually means subclassing, wiring up _transform, choosing objectMode, and minding backpressure. through2 wraps a function instead.
// Without through2
import { Transform } from 'node:stream'
class Upper extends Transform {
_transform (chunk, _enc, cb) {
this.push(chunk.toString().toUpperCase())
cb()
}
}
input.pipe(new Upper()).pipe(output)
// With through2
import { transform } from 'through2'
input.pipe(transform(async (chunk) => chunk.toString().toUpperCase())).pipe(output)The left side is five lines of ceremony and the right side is one. The four things the README lists, the subclassing, the _transform wiring, the objectMode decision and the backpressure, are all handled by the library rather than by the author of the stage.
The same idea is offered for the modern, cross-runtime Web Streams API, through a second entry point:
import { transform } from 'through2/web'
await response.body
.pipeThrough(transform(async (chunk) => /* ... */))
.pipeTo(destination)The package installs with npm install through2, and the author is Rod Vagg, which explains the pedigree: this is the utility that many Node stream pipelines were written against before the native streams API caught up.
The last push was on 2026-10-13, the version is 5.0.11 with 5.0.10 and 5.0.9 from late July 2026, the licence is MIT, and the default branch is master.
Two entry points, and why the difference is not the runtime
The API section draws a table of the two import paths, and then explains something that is easy to get wrong. The two entries differ in the stream API they target, not in the runtime they run on. Either entry can run in Node.js, browsers, Deno, Bun, or Cloudflare Workers. You pick the one that matches the streams you are piping with.
The from 'through2' entry uses the Node-style streams API, the Readable, Writable and Transform classes and the .pipe() method. It depends on readable-stream, and the README notes that browser bundlers pick up that package's browser field automatically and ship its self-contained shim, so no Node-builtin polyfill is needed.
The from 'through2/web' entry uses the WHATWG Web Streams API, the TransformStream class and the .pipeThrough() method. It has zero runtime dependencies and relies only on TransformStream being a global, which the README notes is the case in modern browsers, in Node.js 18 and later, in Deno, in Bun and in Workers.
That distinction is what makes the package useful in 2026 rather than a relic. The old Node streams API and the Web Streams API are not interchangeable, and a utility that supports both without you rewriting your pipeline is solving a real problem. The zero-dependency web build is also the reason the library can be dropped into a Worker without a bundler complaining.
The package.json confirms the split at the export map level. The main entry has types, import and require conditions pointing at through2.js with the types in types/through2.d.ts, and the ./web subpath points at web.js with types/ web.d.ts. The package is type module, so the source is ESM, and the runtime dependency list has exactly one entry, readable-stream at ^4.7.0.
Three function styles, dispatched by inspecting the function
Every transform-creating export accepts the same three function styles, and the README is specific that they are auto-dispatched by inspecting the function's kind.
// 1. Classic Node-style callback (use `this.push()` and the callback)
transform(function (chunk, encoding, callback) {
this.push(chunk)
callback()
})
// 2. Async function (resolved value is pushed; `undefined` skips)
transform(async (chunk) => chunk.toString().toUpperCase())
// 3. Async generator (1-to-many; full pipeline coroutine)
transform(async function * (source) {
for await (const chunk of source) {
yield chunk
yield chunk
}
})The third style is the one that makes this more than a convenience wrapper. An async generator receives the source stream and can yield zero, one or many values per input, which turns a transform into a coroutine and lets you implement flatMap, batch and other one-to-many operations without any special library support.
The note on async is a warning worth reading twice. The async function form does not use this.push. To emit zero or many chunks per input, use the async generator form. To emit one chunk per input, or to skip, return the value or undefined.
That third point is the one that catches people. Returning undefined skips the chunk, which is how filtering works, and it is also how you accidentally lose data if your function returns nothing on a path where it should have returned a value.
A flush function may be passed as the trailing argument, and it follows the same dispatch rules, so the end-of-stream behaviour can be asynchronous if it needs to be.
The recipes, and the two flags worth knowing
The Recipes section is short and each entry is a pattern you would otherwise write by hand, which is the clearest statement of what this library is for.
Map is one in, one out, using the async function form so the resolved value is pushed. Filter returns undefined to drop a chunk. FlatMap is one in, many out, using the async generator form. Batch collects a fixed-size batch and emits at size or at flush, with the example collecting 100 items at a time and flushing a remainder at the end. Tap is a side effect that passes the value through unchanged.
objectTransform(async (item) => doSomething(item))
objectTransform(async (item) => predicate(item) ? item : undefined)
objectTransform(async function * (source) {
for await (const item of source) {
for (const x of expand(item)) yield x
}
})Two flags matter in practice. The first is na_pass in the sense that returning undefined skips, which is the filter mechanism, and the second is the object mode distinction, which the v4 to v5 migration table makes explicit. The legacy call through2.obj(fn) maps to the named export objectTransform(fn), and through2(fn) maps to transform(fn), while through2.ctor(fn) maps to transformer(fn).
The migration section is worth reading in full because v5 is a major version bump with several behaviour changes. The package ships as ESM and new code should import it, though CommonJS callers can still require('through2') on Node.js 22.12 and later via require(esm), and on older Node you convert to import or pin to through2@4. The default export still works, so through2(fn), through2.obj(fn) and through2.ctor(fn) produce equivalent stream instances, and once your callers are converted to ESM imports the call-site syntax and runtime behaviour are unchanged.
There is one caveat in that list and it is a real one. transformer and .ctor now return a factory function rather than a true constructor. The returned instance is still a Transform, but instanceof YourFactory no longer holds, so you use instanceof Transform instead. That is the kind of change that breaks a test suite silently rather than loudly, which is why it is called out here.
The repository layout is small and matches the shape of the library, with through2.js and web.js as the two entry points, types/ for the TypeScript definitions, test/ for the tests, an example-ndjson.js at the root, a tsconfig.json, and a CHANGELOG.md. The test setup is interesting: the tests run under polendina, once in Node with bare-async and once in the browser with the cleanup and runner flags, and the CI script runs lint, the Node tests and the browser tests, so the same assertions are checked in both stream worlds.
Editorial conclusion
through2 suits a codebase that builds stream pipelines and wants the stages to read as functions rather than classes, and it suits a project that is moving from Node streams to Web Streams, because the two entry points target the two stream APIs with the same mental model. It is the wrong choice if you need the full Transform surface, since a function wrapper gives you the transform step and not the rest of the stream lifecycle. Before upgrading from v4, read the migration notes, because v5 is a major version with an ESM source and a factory that no longer supports instanceof, and check the runtime you target, since the web entry point relies on TransformStream being a global.
Frequently asked questions
What does through2 do?
It wraps a function into a stream transform instead of requiring you to subclass Transform, wire up _transform, choose objectMode and handle backpressure. The same package also offers a through2/web entry point for Web Streams pipelines.
What is the difference between through2 and through2/web?
The two entries differ in the stream API they target, not the runtime. from 'through2' targets Node-style streams with .pipe() and depends on readable-stream, while from 'through2/web' targets the WHATWG Web Streams API with .pipeThrough() and has zero runtime dependencies. Either can run in Node, browsers, Deno, Bun or Cloudflare Workers.
Which function styles can I pass to through2?
Three, auto-dispatched by inspecting the function's kind: the classic Node-style callback form using this.push and the callback, an async function whose resolved value is pushed, and an async generator for one-to-many output. A flush function may be passed as the trailing argument and follows the same dispatch rules.
What changed between through2 v4 and v5?
v5 ships as ESM with named exports transform, objectTransform and transformer, keeps the default export working, accepts async functions and async generators, adds the through2/web subpath, moves to readable-stream@4, and changes transformer and .ctor to return a factory function, so instanceof YourFactory no longer holds and you should use instanceof Transform instead.
How do I filter chunks with through2?
Return undefined from the async function form, which is the documented way to skip a chunk, as in objectTransform(async (item) => predicate(item) ? item : undefined). To emit zero or many chunks per input you need the async generator form instead.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/rvagg-through2)Star history chart for your README
[](https://hysenlabs.com/projects/rvagg-through2)Was this useful?