@ljharb/through
A maintained fork of Dominic Tarr's tiny `through` module for constructing an old-style Node readable and writable stream from synchronous write and end callbacks. Call `this.queue(chunk)` inside the write callback to emit or buffer output, and `this.queue(null)` to end the readable side. The fork keeps the original API while adding TypeScript declarations and dependency upkeep. It is compatibility infrastructure for packages built around classic streams, not a modern replacement for Node's Transform class.
A careful maintenance fork for software already coupled to `through`, with types as its clearest improvement. For new code, Node's built-in Transform or through2 gives you a stream contract that handles modern backpressure and asynchronous work more honestly.
Use it if
- You maintain a dependency that already expects the original through 2.3 callback and queue API
- You need a small synchronous pass-through, filter, or one-input-to-many-output stream in CommonJS
- You need the old through API with bundled TypeScript declarations and a maintained npm fork
- You must preserve support for very old Node versions where modern stream helpers are unavailable
- You are writing a new Node application: node:stream Transform and PassThrough are built in, follow current stream semantics, and avoid another dependency
- Your transform performs asynchronous work: the write callback has no completion callback or promise contract, so write() returns before an async operation finishes and backpressure becomes incorrect
- You need explicit objectMode, highWaterMark, encoding, construct, flush, or final controls: the only documented constructor option is autoDestroy
- You publish ESM-only code: version 2.3.14 is a CommonJS package with module.exports, no exports map, and an export-equals TypeScript declaration
- You want modern Streams2+ internals and error lifecycle behavior: the implementation creates a base stream.Stream, manages readable and writable flags itself, and does not provide Transform's _transform error callback
Setup reality
Installation is just `npm install @ljharb/through`; there are no peer dependencies, credentials, native builds, or configuration files. The friction is conceptual. This package deliberately preserves a stream design from 2012. Import it with require(), then pass ordinary functions, not arrow functions, because the package supplies the stream as `this`. Output goes through `this.queue(value)` or its `push` alias. Passing null is not data: it marks the readable side ended, and later queue calls are ignored. The write callback is synchronous and receives no `done` callback, so it is not a safe place for awaited I/O or delayed output. queue() buffers while paused and drains on resume; directly emitting `data` bypasses that buffering. write() reports `!paused`, but the implementation does not expose modern highWaterMark-based pressure controls. The default end callback queues null, while a custom end callback must do that itself or explicitly emit end. By default autoDestroy emits close after both sides end; set it false only if older consumers require a different close lifecycle. Errors thrown by your callback propagate synchronously, and errors from piped sources are not automatically forwarded, so add error listeners or use node:stream pipeline around the chain. TypeScript declarations are included, but they use `export =`, matching `import through = require('@ljharb/through')` or a default import only when your compiler interop settings allow it. The package depends on call-bind and supports Node 0.4+, a compatibility range that explains the old API rather than recommending it for current code.
Patterns
Create a transparent pass-through streampass-through
const through = require('@ljharb/through')
const passthrough = through()
source.pipe(passthrough).pipe(destination)With no callbacks, each chunk is queued unchanged and the readable side ends when the writable side ends.
Transform each chunk synchronouslymap-chunks
const upper = through(function write(chunk) {
this.queue(Buffer.from(chunk.toString().toUpperCase()))
})
source.pipe(upper).pipe(process.stdout)Use a normal function so `this` is the through stream. Arrow functions cannot receive the bound stream context.
Drop unwanted chunksfilter-chunks
const nonEmpty = through(function write(chunk) {
if (chunk.length > 0) this.queue(chunk)
})Not calling queue drops the input chunk. The write callback still completes synchronously.
Emit multiple chunks for one inputexpand-chunks
const lines = through(function write(chunk) {
for (const line of chunk.toString().split('\n')) {
this.queue(line + '\n')
}
})Chunk boundaries are arbitrary in byte streams. This example splits each chunk independently and is not a correct parser for lines spanning chunks.
Emit a final chunk before endingflush-on-end
const footer = through(
function write(chunk) { this.queue(chunk) },
function end() {
this.queue('\n-- done --\n')
this.queue(null)
},
)A custom end callback replaces the default, so it must queue null to finish the readable side.
Pause output and drain the internal buffer laterpause-and-resume
const gate = through()
gate.pause()
source.pipe(gate)
setTimeout(() => {
gate.resume()
gate.pipe(destination)
}, 100)queue() buffers while paused. The buffer has no configurable size limit, so a long pause can retain unbounded data.
Honor the boolean returned by writeinspect-backpressure
if (!transform.write(chunk)) {
transform.once('drain', writeNext)
} else {
writeNext()
}write() returns false only when this stream is paused. It does not use a modern writable highWaterMark. Prefer pipe() or pipeline() when possible.
Write a last chunk and endend-with-data
const stream = through()
stream.on('data', (chunk) => console.log(chunk))
stream.end('last value')end(value) writes the value before running the end callback. Repeated end calls are ignored.
End output earlystop-readable-side
const firstMatch = through(function write(chunk) {
if (chunk.toString().includes('needle')) {
this.queue(chunk)
this.queue(null)
}
})After queue(null), later queue calls are ignored, but the writable side can still receive input until it is ended or destroyed.
Keep close from firing automaticallydisable-auto-destroy
const stream = through(
function write(chunk) { this.queue(chunk) },
null,
{ autoDestroy: false },
)The default is true. Disable it only for consumers that depend on the legacy end and close sequence, then destroy the stream yourself.
Emit data without pause bufferingbypass-buffering
const immediate = through(function write(chunk) {
this.emit('data', chunk)
}, function end() {
this.emit('end')
})Direct data emission bypasses the package's queue and pause buffer. The README documents this mode, but it also bypasses the main reason to use the helper.
Wrap a through stream in pipelinepipeline-errors
const { pipeline } = require('node:stream')
const through = require('@ljharb/through')
const transform = through(function write(chunk) {
this.queue(chunk)
})
pipeline(source, transform, destination, (error) => {
if (error) console.error('pipeline failed', error)
})The helper does not automatically forward every source error. pipeline centralizes teardown and error reporting for the chain.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| through2 | npm | You need the familiar callback transform style with Streams2 Transform behavior and object-mode constructors |
| stream-transform | npm | You need asynchronous record transformation with parallelism and a completion callback |
| streamx | npm | You want a modern userland stream implementation with explicit readable and writable classes |
| through | npm | An existing dependency requires the original unscoped package and changing identity would add no value |