Build a tiny, fast, chainable utility library from scratch — complete with lazy evaluation, mixins, and TypeScript types.
Introduction
Fluent, chainable APIs are addictive:
_(users).map('name').filter(Boolean).sortBy().take(5).value();
They read like English, encourage small reusable steps, and keep state local to the chain. Lodash popularized the style with _.chain()
and later implicit chaining via _()
. But you don’t need a 70KB dependency to get most of the benefits. In this tutorial, we’ll build a tiny, chainable utility that supports:
- Implicit chaining:
_(data).map(...).filter(...).value()
- Lazy evaluation: operations are queued and executed only on
.value()
- Composable ops:
map
,filter
,take
,drop
,uniq
,sortBy
,tap
,thru
- Mixins: add your own methods on the fly
- Immutability-friendly: don’t mutate inputs
- TypeScript typings (optional section)
- Async variant (bonus section)
By the end, you’ll understand how chainable libraries work under the hood and have a copy-paste mini-Lodash you can extend.
1) The Mental Model
Think of a chain as a pipeline description:
- Source: an initial collection (array/iterable).
- Stages: a list of operations (
map
,filter
,take
, …) and their parameters. - Sink:
.value()
that executes the pipeline over the source.
Lazy execution means we don’t transform data at each step. We just record the steps and run them once at the end — great for performance and composability.
2) Minimal Chain Core
We’ll start with a small wrapper that stores:
source
(the initial data)steps
(an array of{ type, fn, args }
describing operations)
function _(source) {
return new Chain(source);
}
class Chain {
constructor(source) {
this._source = source;
this._steps = []; // queued operations
}
_push(type, fn, ...args) {
this._steps.push({ type, fn, args });
return this; // enable chaining
}
value() {
let result = this._source;
for (const { type, fn, args } of this._steps) {
result = fn(result, ...args);
}
return result;
}
}
This works, but it’s not lazy per element; it’s lazy per chain. That’s fine to start. We’ll add element-level laziness soon.
3) Implement Core Operations
Let’s define some pure helpers and wire them into Chain
by pushing steps.
// helpers (pure)
const mapArray = (arr, iteratee) => arr.map(iteratee);
const filterArray = (arr, predicate) => arr.filter(predicate);
const takeArray = (arr, n = 1) => arr.slice(0, Math.max(0, n));
const dropArray = (arr, n = 1) => arr.slice(Math.max(0, n));
const uniqArray = (arr) => Array.from(new Set(arr));
const sortByArray = (arr, by = x => x) =>
[...arr].sort((a, b) => (by(a) > by(b)) ? 1 : (by(a) < by(b) ? -1 : 0));
// chain methods
Chain.prototype.map = function (iteratee) {
return this._push('map', mapArray, iteratee);
};
Chain.prototype.filter = function (predicate) {
return this._push('filter', filterArray, predicate);
};
Chain.prototype.take = function (n) {
return this._push('take', takeArray, n);
};
Chain.prototype.drop = function (n) {
return this._push('drop', dropArray, n);
};
Chain.prototype.uniq = function () {
return this._push('uniq', uniqArray);
};
Chain.prototype.sortBy = function (by) {
return this._push('sortBy', sortByArray, by);
};
// escape hatch
Chain.prototype.tap = function (fn) {
// run a side-effecting function during execution, but pass-through result
return this._push('tap', (value, f) => (f(value), value), fn);
};
Chain.prototype.thru = function (fn) {
// transform value via fn (not necessarily collection-only)
return this._push('thru', (value, f) => f(value), fn);
};
Usage:
const result = _([3, 1, 2, 2, 3, 4])
.uniq()
.filter(n => n % 2 === 0)
.sortBy()
.take(2)
.value();
// -> [2, 4]
Takeaway: We’ve got a chainable interface with lazy evaluation at the chain level.
4) Element-Level Laziness with Iterables
The previous version eagerly re-materializes arrays on each step during .value()
. For large data or early termination ops (take
), we can do better with lazy iterables.
We’ll compile the pipeline into a generator that processes values on demand:
function asIterable(src) {
if (src == null) return [];
return (typeof src[Symbol.iterator] === 'function') ? src : [src];
}
// Lazy combinators that return generator functions
const lazy = {
map: (iteratee) => function* (iter) {
for (const v of iter) yield iteratee(v);
},
filter: (predicate) => function* (iter) {
for (const v of iter) if (predicate(v)) yield v;
},
take: (n = 1) => function* (iter) {
let i = 0;
if (n <= 0) return;
for (const v of iter) {
yield v;
if (++i >= n) break;
}
},
drop: (n = 1) => function* (iter) {
let i = 0;
for (const v of iter) {
if (i++ >= n) yield v;
}
}
};
// Non-lazy (terminal or whole-collection) helpers
const toArray = (iter) => Array.from(iter);
const uniqLazy = function* (iter) {
const seen = new Set();
for (const v of iter) {
if (!seen.has(v)) {
seen.add(v);
yield v;
}
}
};
const sortByWhole = (iter, by = x => x) =>
toArray(iter).sort((a, b) => (by(a) > by(b)) ? 1 : (by(a) < by(b) ? -1 : 0));
// Rework Chain.value() to compose lazy stages
class LazyChain {
constructor(source) {
this._source = source;
this._stages = []; // array of (iter) => iter
}
_push(stageFactory) {
this._stages.push(stageFactory);
return this;
}
value() {
let iter = asIterable(this._source);
// Apply all lazy stages in order
for (const makeStage of this._stages) {
iter = makeStage(iter);
}
// If the last stage is still an iterable, materialize to array by default
return Array.isArray(iter) ? iter : Array.from(iter);
}
}
function $ (source) { return new LazyChain(source); }
Now add methods using lazy stages:
LazyChain.prototype.map = function (fn) {
return this._push(lazy.map(fn));
};
LazyChain.prototype.filter = function (pred) {
return this._push(lazy.filter(pred));
};
LazyChain.prototype.take = function (n) {
return this._push(lazy.take(n));
};
LazyChain.prototype.drop = function (n) {
return this._push(lazy.drop(n));
};
LazyChain.prototype.uniq = function () {
return this._push((iter) => uniqLazy(iter));
};
LazyChain.prototype.sortBy = function (by) {
// sortBy needs the whole input; keep it terminal by wrapping
return this._push((iter) => sortByWhole(iter, by));
};
LazyChain.prototype.tap = function (fn) {
return this._push((iter) => {
// materialize to let user inspect, but feed it back as iterable
const arr = toArray(iter);
fn(arr);
return arr;
});
};
LazyChain.prototype.thru = function (fn) {
return this._push((iter) => fn(iter)); // user can return iterable or array
};
Usage:
const res = $(function* () { // any iterable, not just arrays
for (let i = 0; i < 1e6; i++) yield i;
}())
.drop(10)
.filter(x => x % 2 === 0)
.take(3)
.value(); // -> [10, 12, 14]
Notice how we never touch the remaining ~1e6 elements thanks to laziness.
Rule of thumb: make everything lazy unless it requires the full collection (e.g.,
sortBy
,reverse
,groupBy
).
5) Path Shorthands (map('prop')
, sortBy('prop')
)
Lodash accepts property-name shorthands. We can support simple versions:
function iteratee(x) {
if (typeof x === 'function') return x;
if (typeof x === 'string') return (obj) => obj?.[x];
if (Array.isArray(x)) {
// [['a','b']] -> obj => obj?.a?.b
return (obj) => x.reduce((acc, key) => acc?.[key], obj);
}
return (v) => v;
}
LazyChain.prototype.map = function (it) {
return this._push(lazy.map(iteratee(it)));
};
LazyChain.prototype.sortBy = function (by) {
return this._push((iter) => sortByWhole(iter, iteratee(by)));
};
Now you can:
const names = $([
{ id: 1, user: { name: 'Ava' } },
{ id: 2, user: { name: 'Ben' } },
]).map(['user', 'name']).value(); // -> ['Ava','Ben']
6) Mixins: Extend at Runtime
Give users a way to add new chainable ops:
function mixin(name, stageFactory) {
if (name in LazyChain.prototype) {
throw new Error(`Method ${name} already exists`);
}
LazyChain.prototype[name] = function (...args) {
return this._push(stageFactory(...args));
};
}
// Example: compact (remove falsy)
mixin('compact', () => (iter) => (function*() {
for (const v of iter) if (v) yield v;
})());
// Usage
const out = $([0, 1, false, 2, '', 3]).compact().value(); // [1,2,3]
Want a terminal/mutating-style mixin (like sum
)? Return a stage that materializes:
mixin('sum', (sel) => (iter) => {
const f = iteratee(sel);
return toArray(iter).reduce((a, v) => a + (f ? f(v) : v), 0);
});
const total = $([{n:2},{n:5}]).sum('n').value(); // 7
(Because .value()
always materializes, returning a number is fine.)
7) Immutability & Safety
- All ops should avoid mutating the input.
- For whole-collection ops, create copies (
[...arr]
) before sorting or reversing. - Document any intentional side effect (e.g.,
tap
).
This keeps the chain predictable and friendly to React/state tooling.
8) Performance Notes
- Lazy iterables shine for early-terminating chains (
take
,find
,some
). - Whole-collection ops (like
sortBy
,uniqBy
when implemented with a Set) are still efficient, but they must materialize. - Iterator composition is very fast in modern engines; micro-optimizations rarely beat clear generators.
- Avoid hidden conversions inside each stage; pass through iterables until the end.
9) API Round-Up (What We Have)
Lazy (per-element):
map(fn | 'prop' | ['deep','prop'])
filter(pred | 'prop' | ['path'])
take(n)
,drop(n)
uniq()
(lazy set check)compact()
(via mixin example)tap(fn)
(side effects)thru(fn)
(transform iterable)
Whole-collection:
sortBy(fn | 'prop' | ['path'])
sum(sel)
(mixin example)
Terminal:
.value()
→ materialize to array or final transformed value
10) Real-World Scenarios
10.1 Pagination & Search (UI)
const page = $(
products
).filter(p => p.inStock)
.filter(p => p.category === activeCategory)
.map('name')
.sortBy()
.drop((currentPage - 1) * pageSize)
.take(pageSize)
.value();
Why chains here? Readability, easy reordering, lazy drop/take efficiency.
10.2 CSV → Objects → Aggregates (Node)
const lines = $(fs.readFileSync('sales.csv','utf8').split('\n'))
.drop(1) // header
.map(line => line.split(','))
.map(([sku, qty, price]) => ({ sku, qty: +qty, price: +price }));
const totals = lines
.thru(iter => {
const map = new Map();
for (const { sku, qty, price } of iter) {
map.set(sku, (map.get(sku) || 0) + qty * price);
}
return map; // preserve as whole structure through .value()
})
.value(); // Map<sku, total>
10.3 React Selectors (Derived Data)
const getVisible = (state) => $(
state.todos
).filter(t => !t.deleted)
.filter(t => state.filter === 'all' ||
(state.filter === 'done' ? t.done : !t.done))
.sortBy('createdAt')
.value();
Stable, pure, readable.
11) TypeScript Types (Practical)
A minimal typing approach:
type Iteratee<T, R> = ((v: T) => R) | string | Array<string | number>;
export class LazyChain<T> {
private _source: Iterable<T>;
private _stages: Array<(iter: Iterable<any>) => Iterable<any>>;
constructor(source: Iterable<T>);
map<R>(it: Iteratee<T, R>): LazyChain<R>;
filter(pred: Iteratee<T, boolean>): LazyChain<T>;
take(n: number): LazyChain<T>;
drop(n: number): LazyChain<T>;
uniq(): LazyChain<T>;
sortBy<K>(by?: Iteratee<T, K>): LazyChain<T>;
tap(fn: (value: T[]) => void): LazyChain<T>;
thru<R>(fn: (iter: Iterable<T>) => Iterable<R> | R[]): LazyChain<R>;
value(): T[]; // or R[] based on last transform
}
For production, you can refine value()
’s return type using generics that track stages, but the above is good enough for app-level safety.
12) Async Variant (Bonus)
Sometimes you want to chain over async sources (fetches, streams). We can mirror the API with AsyncIterable
and for await
.
class AsyncChain {
constructor(source) {
this._source = source; // Promise | AsyncIterable | Iterable
this._stages = [];
}
_push(stage) { this._stages.push(stage); return this; }
static asAsyncIter(src) {
if (src?.[Symbol.asyncIterator]) return src;
if (src?.then) { // Promise -> single-value async iter
return (async function* () { yield await src; }());
}
if (src?.[Symbol.iterator]) {
return (async function* () { yield* src; }());
}
return (async function* () {})();
}
map(fn) {
return this._push(async function* (iter) {
for await (const v of iter) yield await fn(v);
});
}
filter(pred) {
return this._push(async function* (iter) {
for await (const v of iter) if (await pred(v)) yield v;
});
}
take(n=1) {
return this._push(async function* (iter) {
let i = 0;
for await (const v of iter) {
yield v; if (++i >= n) break;
}
});
}
async value() {
let iter = AsyncChain.asAsyncIter(this._source);
for (const stage of this._stages) {
iter = stage(iter);
}
const out = [];
for await (const v of iter) out.push(v);
return out;
}
}
// Usage
const usersP = fetch('/api/users').then(r => r.json());
const list = await new AsyncChain(usersP)
.map(u => u.name)
.filter(Boolean)
.take(10)
.value();
13) Packaging, Tree-Shaking & DX Tips
- Put the tiny library in
src/chain.ts
/.js
and export named functions for tree-shaking (e.g.,map
,filter
factories), plus the default$(source)
factory. - Keep deps = 0. This is the whole point.
- Expose a
mixin
hook; document that mixins should be pure (no mutation) and lazy whenever possible. - Add a
from(iterable)
static andof(...values)
convenience.
14) Testing the Pipeline
- Unit-test each stage (map/filter/take/drop/uniq/sortBy/tap/thru).
- Property-based test: the output of
drop(a).take(b)
should never exceedb
. - Immutability test: input arrays remain unchanged after
.value()
. - Performance test: chaining
drop(1e6).take(1)
over a generator should run in ~O(1) steps, not O(n^2).
15) Common Gotchas (and Fixes)
- Accidental materialization in every step → ensure stages pass through iterables and only materialize when necessary.
- Mutating original arrays (e.g.,
sort
) → clone first. - Assuming arrays only → accept any iterable to make your lib generally useful.
- Path shorthand ambiguity → keep it simple (
'prop'
and['deep','prop']
). Document clearly. - Mixins that break laziness → allowed, but mark them as terminal/whole-collection in docs.
16) Full Minimal Implementation (Copy–Paste)
export function $(source) { return new LazyChain(source); }
class LazyChain {
constructor(source) {
this._source = source;
this._stages = [];
}
_push(stageFactory) { this._stages.push(stageFactory); return this; }
value() {
let iter = asIterable(this._source);
for (const makeStage of this._stages) iter = makeStage(iter);
return Array.isArray(iter) ? iter : Array.from(iter);
}
map(it) { return this._push(lazy.map(iteratee(it))); }
filter(p) { return this._push(lazy.filter(iteratee(p))); }
take(n=1) { return this._push(lazy.take(n)); }
drop(n=1) { return this._push(lazy.drop(n)); }
uniq() { return this._push((iter) => uniqLazy(iter)); }
sortBy(by) { return this._push((iter) => sortByWhole(iter, iteratee(by))); }
tap(fn) { return this._push((iter) => { const a = toArray(iter); fn(a); return a; }); }
thru(fn) { return this._push((iter) => fn(iter)); }
}
export function mixin(name, stageFactory) {
if (name in LazyChain.prototype) throw new Error(`Method ${name} exists`);
LazyChain.prototype[name] = function (...args) {
return this._push(stageFactory(...args));
};
}
// helpers
function asIterable(src) {
if (src == null) return [];
return (typeof src[Symbol.iterator] === 'function') ? src : [src];
}
const toArray = (iter) => Array.from(iter);
const lazy = {
map: (f) => function* (iter) { for (const v of iter) yield f(v); },
filter: (p) => function* (iter) { for (const v of iter) if (p(v)) yield v; },
take: (n=1) => function* (iter) {
if (n <= 0) return; let i=0;
for (const v of iter) { yield v; if (++i >= n) break; }
},
drop: (n=1) => function* (iter) {
let i=0; for (const v of iter) { if (i++ >= n) yield v; }
}
};
function uniqLazy(iter) {
return (function* () {
const seen = new Set();
for (const v of iter) if (!seen.has(v)) { seen.add(v); yield v; }
})();
}
function iteratee(x) {
if (typeof x === 'function') return x;
if (typeof x === 'string') return (obj) => obj?.[x];
if (Array.isArray(x)) return (obj) => x.reduce((a, k) => a?.[k], obj);
return (v) => v;
}
function sortByWhole(iter, by = x => x) {
return toArray(iter).sort((a, b) => (by(a) > by(b)) - (by(a) < by(b)));
}
Conclusion
You just built a chainable utility library with:
- A clean fluent API
- Lazy, iterable-based processing
- Mixins for extension
- Shorthand iteratees
- Immutability-friendly semantics
This is enough to cover a big chunk of what you use Lodash chains for, while keeping your bundle tiny and your mental model clear. And because it’s all vanilla JS, you can customize it for your project’s exact needs.
Pro tip: Start with the minimal set your team actually uses (map
, filter
, take
, drop
, sortBy
, uniq
). Add mixins as real use cases appear. Keep it lazy by default.
Call to Action
What chainable operation do you reach for most — groupBy
, flatMap
, or zip
?
💬 Tell me which method you want next and I’ll add a lazy mixin example.
🔖 Bookmark this as your mini-Lodash starter.
👩💻 Share with a teammate who’s trying to shave a dependency from your bundle.
Leave a Reply