"A Duplex is Readable AND Writable bolted together. A Transform is a Duplex whose write side feeds its read side. Both sound abstract; both show up in your code every day."
Duplex — Two Independent Halves
A Duplex stream has a Readable side and a Writable side that are independent. What you write doesn't necessarily affect what you read. The canonical example: a network socket. You write outbound bytes; you read inbound bytes; the two flows are separate channels muxed over the same TCP connection.
import { createConnection } from 'node:net';
const sock = createConnection({ host: 'example.com', port: 80 });
// Writable side — send a request
sock.write('GET / HTTP/1.1\r\nHost: example.com\r\n\r\n');
// Readable side — read the response
for await (const chunk of sock) {
process.stdout.write(chunk);
}
Duplex matters when you need both directions but they're conceptually unrelated — sockets, WebSocket connections, child process stdio (each pipe is a Duplex from the parent's view).
Transform — Input Becomes Output
A Transform stream is a special Duplex: bytes written in get transformed and emitted on the readable side. Same stream object, both ends, but the two sides are connected. Examples you use constantly:
zlib.createGzip()— write raw bytes, read gzipped bytes.zlib.createGunzip()— write gzipped bytes, read raw bytes.crypto.createHash('sha256')— write data, read the digest at the end.crypto.createCipheriv(...)— write plaintext, read ciphertext.
All of these compose. file → gzip → encrypt → upload is just four streams piped together, and bounded memory regardless of input size.
Building a Custom Transform
transform(chunk, encoding, callback) function — call callback() when you've processed the chunk (with optional next output via this.push(value)). Flush logic in optional flush(callback).import { Transform } from 'node:stream';
class UppercaseUtf8 extends Transform {
_transform(chunk, _enc, cb) {
this.push(chunk.toString('utf-8').toUpperCase());
cb();
}
}
// or as a one-liner via the Transform constructor
import { Transform } from 'node:stream';
const upper = new Transform({
transform(chunk, _enc, cb) {
cb(null, chunk.toString('utf-8').toUpperCase());
},
});
// Use it
process.stdin.pipe(upper).pipe(process.stdout);The pattern is: read chunk, do work, push result, signal done. That's the whole API for 90% of transforms.Object-Mode Transforms — CSV, JSON, Records
Transforms in object mode let you build clean pipelines for structured data. A CSV parser: input bytes, output row objects. A line-splitter: input bytes, output strings (one per line). An enricher: input record objects, output the same objects with extra fields filled in.
import { Transform } from 'node:stream';
class ParseCsvRow extends Transform {
constructor() { super({ objectMode: true }); this.cols = null; }
_transform(line, _enc, cb) {
const fields = String(line).split(',');
if (!this.cols) { this.cols = fields; cb(); return; }
const obj = Object.fromEntries(this.cols.map((c, i) => [c, fields[i]]));
cb(null, obj);
}
}
Composing Many Transforms
The power of Transform is composition. fileStream → split-by-line → parse-csv → filter-by-condition → write-to-db is five stages, each independent, each unit-testable. Stage 3 can change without rewriting 4. That's why streams scale in code complexity, not just memory.