From 884d812acde3f5f92257586cb4b622f4502ffe9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Egl=C4=ABtis?= Date: Mon, 26 Jan 2026 10:38:02 +0200 Subject: [PATCH 1/9] feat: add option to set asyncmap/asyncfilter concurrency pool limit --- README.md | 18 +++++++++--- lib/asyncbox.ts | 65 +++++++++++++++++++++++++++++++++--------- lib/types.ts | 5 ++++ test/asyncbox-specs.ts | 46 +++++++++++++++++++++--------- 4 files changed, 104 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index b625559..b8c23b1 100644 --- a/README.md +++ b/README.md @@ -91,16 +91,26 @@ Then in your async functions, you can do: ```js const items = [1, 2, 3, 4]; const slowSquare = async (n) => { await sleep(5); return n * 2; }; -let newItems = await asyncmap(items, async (i) => { return await slowSquare(i); }); +let newItems = await asyncmap(items, slowSquare); console.log(newItems); // [1, 4, 9, 16]; const slowEven = async (n) => { await sleep(5); return n % 2 === 0; }; -newItems = await asyncfilter(items, async (i) => { return await slowEven(i); }); +newItems = await asyncfilter(items, slowEven); console.log(newItems); // [2, 4]; ``` -By default, `asyncmap` and `asyncfilter` run their operations in parallel; you -can pass `false` as a third argument to make sure it happens serially. +By default, `asyncmap` and `asyncfilter` run their operations in parallel, but you +can set the third argument to `false` to enforce sequential execution, or set a custom +concurrency pool limit using `{concurrency: }`: + +```js +const items = [1, 2, 3, 4]; +const slowSquare = async (n) => { await sleep(5); return n * 2; }; +// this will run sequentially (~20ms) +const newItemsSeq = await asyncmap(items, slowSquare, false); +// this will handle 2 items at a time (~10ms) +const newItemsMaxTwo = await asyncmap(items, slowSquare, {concurrency: 2}); +``` ### waitForCondition diff --git a/lib/asyncbox.ts b/lib/asyncbox.ts index e7bb9ac..77e3d98 100644 --- a/lib/asyncbox.ts +++ b/lib/asyncbox.ts @@ -1,4 +1,4 @@ -import type {LongSleepOptions, WaitForConditionOptions} from './types.js'; +import type {LongSleepOptions, MapFilterOptions, WaitForConditionOptions} from './types.js'; const LONG_SLEEP_THRESHOLD = 5000; // anything over 5000ms will turn into a spin @@ -105,52 +105,91 @@ export async function retryInterval( } /** - * Similar to `Array.prototype.map`; runs in serial or parallel + * Similar to `Array.prototype.map`; runs in parallel, serial, or with custom concurrency pool * @param coll - The collection to map over * @param mapper - The function to apply to each element - * @param runInParallel - Whether to run operations in parallel (default: true) + * @param options - Options for controlling parallelism (default: true - fully parallel) */ export async function asyncmap( coll: T[], mapper: (value: T) => R | Promise, - runInParallel = true, + options: MapFilterOptions = true, ): Promise { - if (runInParallel) { + if (options === true) { return Promise.all(coll.map(mapper)); } - const newColl: R[] = []; - for (const item of coll) { - newColl.push(await mapper(item)); + if (options === false) { + for (const item of coll) { + newColl.push(await mapper(item)); + } + } else { + const concurrency = options.concurrency; + if (concurrency < 1) { + throw new Error('Concurrency option must be a positive number'); + } + let index = 0; + const workers: Promise[] = []; + const worker = async (): Promise => { + while (index < coll.length) { + const currentIndex = index; + index++; + newColl[currentIndex] = await mapper(coll[currentIndex]); + } + }; + for (let i = 0; i < concurrency; i++) { + workers.push(worker()); + } + await Promise.all(workers); } return newColl; } /** - * Similar to `Array.prototype.filter` + * Similar to `Array.prototype.filter`; runs in parallel, serial, or with custom concurrency pool * @param coll - The collection to filter * @param filter - The function to test each element - * @param runInParallel - Whether to run operations in parallel (default: true) + * @param options - Options for controlling parallelism (default: true - fully parallel) */ export async function asyncfilter( coll: T[], filter: (value: T) => boolean | Promise, - runInParallel = true, + options: MapFilterOptions = true, ): Promise { const newColl: T[] = []; - if (runInParallel) { + if (options === true) { const bools = await Promise.all(coll.map(filter)); for (let i = 0; i < coll.length; i++) { if (bools[i]) { newColl.push(coll[i]); } } - } else { + } else if (options === false) { for (const item of coll) { if (await filter(item)) { newColl.push(item); } } + } else { + const concurrency = options.concurrency; + if (concurrency < 1) { + throw new Error('Concurrency option must be a positive number'); + } + let index = 0; + const workers: Promise[] = []; + const worker = async (): Promise => { + while (index < coll.length) { + const currentIndex = index; + index++; + if (await filter(coll[currentIndex])) { + newColl.push(coll[currentIndex]); + } + } + }; + for (let i = 0; i < concurrency; i++) { + workers.push(worker()); + } + await Promise.all(workers); } return newColl; } diff --git a/lib/types.ts b/lib/types.ts index ccbac28..40ec4ca 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -31,6 +31,11 @@ export interface LongSleepOptions { progressCb?: ProgressCallback | null; } +/** + * Options for {@link asyncmap} and {@link asyncfilter} + */ +export type MapFilterOptions = boolean | {concurrency: number}; + /** * Options for {@link waitForCondition} */ diff --git a/test/asyncbox-specs.ts b/test/asyncbox-specs.ts index fbc2c0b..91070b6 100644 --- a/test/asyncbox-specs.ts +++ b/test/asyncbox-specs.ts @@ -210,49 +210,69 @@ describe('asyncmap', function () { await sleep(10); return el * 2; }; - const coll = [1, 2, 3]; + const coll = [1, 2, 3, 4, 5]; + const newColl = [2, 4, 6, 8, 10]; it('should map elements one at a time', async function () { const start = Date.now(); - expect(await asyncmap(coll, mapper, false)).to.eql([2, 4, 6]); - expect(Date.now() - start).to.be.at.least(30); + expect(await asyncmap(coll, mapper, false)).to.eql(newColl); + expect(Date.now() - start).to.be.at.least(50); }); it('should map elements in parallel', async function () { const start = Date.now(); - expect(await asyncmap(coll, mapper)).to.eql([2, 4, 6]); + expect(await asyncmap(coll, mapper)).to.eql(newColl); expect(Date.now() - start).to.be.at.most(20); }); + it('should map elements with concurrency', async function () { + const start = Date.now(); + expect(await asyncmap(coll, mapper, {concurrency: 2})).to.eql(newColl); + expect(Date.now() - start).to.be.at.least(30); + expect(Date.now() - start).to.be.at.most(40); + }); it('should handle an empty array', async function () { expect(await asyncmap([], mapper, false)).to.eql([]); }); it('should handle an empty array in parallel', async function () { expect(await asyncmap([], mapper)).to.eql([]); }); + it('should raise an error for invalid concurrency option', async function () { + await expect(asyncmap(coll, mapper, {concurrency: 0})).to.be.rejectedWith( + 'Concurrency option must be a positive number' + ); + }); }); describe('asyncfilter', function () { const filter = async function (el: number): Promise { - await sleep(5); + await sleep(10); return el % 2 === 0; }; const coll = [1, 2, 3, 4, 5]; + const newColl = [2, 4]; it('should filter elements one at a time', async function () { const start = Date.now(); - expect(await asyncfilter(coll, filter, false)).to.eql([2, 4]); - expect(Date.now() - start).to.be.at.least(19); + expect(await asyncfilter(coll, filter, false)).to.eql(newColl); + expect(Date.now() - start).to.be.at.least(50); }); it('should filter elements in parallel', async function () { const start = Date.now(); - expect(await asyncfilter(coll, filter)).to.eql([2, 4]); - expect(Date.now() - start).to.be.below(9); + expect(await asyncfilter(coll, filter)).to.eql(newColl); + expect(Date.now() - start).to.be.at.most(20); }); - it('should handle an empty array', async function () { + it('should filter elements with concurrency', async function () { const start = Date.now(); + expect(await asyncfilter(coll, filter, {concurrency: 2})).to.eql(newColl); + expect(Date.now() - start).to.be.at.least(30); + expect(Date.now() - start).to.be.at.most(40); + }); + it('should handle an empty array', async function () { expect(await asyncfilter([], filter, false)).to.eql([]); - expect(Date.now() - start).to.be.below(9); }); it('should handle an empty array in parallel', async function () { - const start = Date.now(); expect(await asyncfilter([], filter)).to.eql([]); - expect(Date.now() - start).to.be.below(9); + }); + it('should raise an error for invalid concurrency option', async function () { + await expect(asyncfilter(coll, filter, {concurrency: 0})).to.be.rejectedWith( + 'Concurrency option must be a positive number' + ); }); }); From 7a5c304a6b244db34a1b3cddfca9c6041500e8e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Egl=C4=ABtis?= Date: Mon, 26 Jan 2026 11:04:41 +0200 Subject: [PATCH 2/9] address copilot comments --- README.md | 4 ++-- lib/asyncbox.ts | 18 +++++++++++------- test/asyncbox-specs.ts | 28 ++++++++++++++++++++-------- 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index b8c23b1..0a75052 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Then in your async functions, you can do: ```js const items = [1, 2, 3, 4]; -const slowSquare = async (n) => { await sleep(5); return n * 2; }; +const slowSquare = async (n) => { await sleep(5); return n * n; }; let newItems = await asyncmap(items, slowSquare); console.log(newItems); // [1, 4, 9, 16]; @@ -105,7 +105,7 @@ concurrency pool limit using `{concurrency: }`: ```js const items = [1, 2, 3, 4]; -const slowSquare = async (n) => { await sleep(5); return n * 2; }; +const slowSquare = async (n) => { await sleep(5); return n * n; }; // this will run sequentially (~20ms) const newItemsSeq = await asyncmap(items, slowSquare, false); // this will handle 2 items at a time (~10ms) diff --git a/lib/asyncbox.ts b/lib/asyncbox.ts index 77e3d98..5df9ec1 100644 --- a/lib/asyncbox.ts +++ b/lib/asyncbox.ts @@ -125,8 +125,8 @@ export async function asyncmap( } } else { const concurrency = options.concurrency; - if (concurrency < 1) { - throw new Error('Concurrency option must be a positive number'); + if (!Number.isInteger(concurrency) || concurrency < 1) { + throw new Error('Concurrency option must be a positive integer'); } let index = 0; const workers: Promise[] = []; @@ -172,24 +172,28 @@ export async function asyncfilter( } } else { const concurrency = options.concurrency; - if (concurrency < 1) { - throw new Error('Concurrency option must be a positive number'); + if (!Number.isInteger(concurrency) || concurrency < 1) { + throw new Error('Concurrency option must be a positive integer'); } let index = 0; + const bools: boolean[] = new Array(coll.length); const workers: Promise[] = []; const worker = async (): Promise => { while (index < coll.length) { const currentIndex = index; index++; - if (await filter(coll[currentIndex])) { - newColl.push(coll[currentIndex]); - } + bools[currentIndex] = await filter(coll[currentIndex]); } }; for (let i = 0; i < concurrency; i++) { workers.push(worker()); } await Promise.all(workers); + for (let i = 0; i < coll.length; i++) { + if (bools[i]) { + newColl.push(coll[i]); + } + } } return newColl; } diff --git a/test/asyncbox-specs.ts b/test/asyncbox-specs.ts index 91070b6..8a5aa24 100644 --- a/test/asyncbox-specs.ts +++ b/test/asyncbox-specs.ts @@ -234,10 +234,16 @@ describe('asyncmap', function () { it('should handle an empty array in parallel', async function () { expect(await asyncmap([], mapper)).to.eql([]); }); - it('should raise an error for invalid concurrency option', async function () { - await expect(asyncmap(coll, mapper, {concurrency: 0})).to.be.rejectedWith( - 'Concurrency option must be a positive number' - ); + [ + {desc: 'a zero', val: 0}, + {desc: 'a negative', val: -1}, + {desc: 'a non-integer', val: 2.5}, + ].forEach(async ({desc, val}) => { + it(`should raise an error for ${desc} concurrency option value`, async function () { + await expect(asyncmap(coll, mapper, {concurrency: val})).to.be.rejectedWith( + 'Concurrency option must be a positive integer' + ); + }); }); }); @@ -270,9 +276,15 @@ describe('asyncfilter', function () { it('should handle an empty array in parallel', async function () { expect(await asyncfilter([], filter)).to.eql([]); }); - it('should raise an error for invalid concurrency option', async function () { - await expect(asyncfilter(coll, filter, {concurrency: 0})).to.be.rejectedWith( - 'Concurrency option must be a positive number' - ); + [ + {desc: 'a zero', val: 0}, + {desc: 'a negative', val: -1}, + {desc: 'a non-integer', val: 2.5}, + ].forEach(async ({desc, val}) => { + it(`should raise an error for ${desc} concurrency option value`, async function () { + await expect(asyncfilter(coll, filter, {concurrency: val})).to.be.rejectedWith( + 'Concurrency option must be a positive integer' + ); + }); }); }); From 6050a00558c4c1ce64aff895c031685dc33c605f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Egl=C4=ABtis?= Date: Mon, 26 Jan 2026 11:08:06 +0200 Subject: [PATCH 3/9] try to reduce test flakiness --- test/asyncbox-specs.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/asyncbox-specs.ts b/test/asyncbox-specs.ts index 8a5aa24..4349c79 100644 --- a/test/asyncbox-specs.ts +++ b/test/asyncbox-specs.ts @@ -225,7 +225,7 @@ describe('asyncmap', function () { it('should map elements with concurrency', async function () { const start = Date.now(); expect(await asyncmap(coll, mapper, {concurrency: 2})).to.eql(newColl); - expect(Date.now() - start).to.be.at.least(30); + expect(Date.now() - start).to.be.at.least(29); expect(Date.now() - start).to.be.at.most(40); }); it('should handle an empty array', async function () { @@ -267,7 +267,7 @@ describe('asyncfilter', function () { it('should filter elements with concurrency', async function () { const start = Date.now(); expect(await asyncfilter(coll, filter, {concurrency: 2})).to.eql(newColl); - expect(Date.now() - start).to.be.at.least(30); + expect(Date.now() - start).to.be.at.least(29); expect(Date.now() - start).to.be.at.most(40); }); it('should handle an empty array', async function () { From 1417aa2442822a721a9eef7a3ea7c3cd79e9cd55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Egl=C4=ABtis?= Date: Mon, 26 Jan 2026 11:08:58 +0200 Subject: [PATCH 4/9] update mocha syntax --- test/asyncbox-specs.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/asyncbox-specs.ts b/test/asyncbox-specs.ts index 4349c79..65f4b9e 100644 --- a/test/asyncbox-specs.ts +++ b/test/asyncbox-specs.ts @@ -238,7 +238,7 @@ describe('asyncmap', function () { {desc: 'a zero', val: 0}, {desc: 'a negative', val: -1}, {desc: 'a non-integer', val: 2.5}, - ].forEach(async ({desc, val}) => { + ].forEach(({desc, val}) => { it(`should raise an error for ${desc} concurrency option value`, async function () { await expect(asyncmap(coll, mapper, {concurrency: val})).to.be.rejectedWith( 'Concurrency option must be a positive integer' @@ -280,7 +280,7 @@ describe('asyncfilter', function () { {desc: 'a zero', val: 0}, {desc: 'a negative', val: -1}, {desc: 'a non-integer', val: 2.5}, - ].forEach(async ({desc, val}) => { + ].forEach(({desc, val}) => { it(`should raise an error for ${desc} concurrency option value`, async function () { await expect(asyncfilter(coll, filter, {concurrency: val})).to.be.rejectedWith( 'Concurrency option must be a positive integer' From 3a06cf8a9507ccafb79d59cb6f717bfadf8f0260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Egl=C4=ABtis?= Date: Mon, 26 Jan 2026 12:54:11 +0200 Subject: [PATCH 5/9] use p-limit to avoid reinventing the wheel --- lib/asyncbox.ts | 42 +++++------------------------------------- package.json | 5 ++++- test/asyncbox-specs.ts | 4 ++-- 3 files changed, 11 insertions(+), 40 deletions(-) diff --git a/lib/asyncbox.ts b/lib/asyncbox.ts index 5df9ec1..368df79 100644 --- a/lib/asyncbox.ts +++ b/lib/asyncbox.ts @@ -1,3 +1,4 @@ +import pLimit from 'p-limit'; import type {LongSleepOptions, MapFilterOptions, WaitForConditionOptions} from './types.js'; const LONG_SLEEP_THRESHOLD = 5000; // anything over 5000ms will turn into a spin @@ -118,31 +119,15 @@ export async function asyncmap( if (options === true) { return Promise.all(coll.map(mapper)); } - const newColl: R[] = []; if (options === false) { + const newColl: R[] = []; for (const item of coll) { newColl.push(await mapper(item)); } + return newColl; } else { - const concurrency = options.concurrency; - if (!Number.isInteger(concurrency) || concurrency < 1) { - throw new Error('Concurrency option must be a positive integer'); - } - let index = 0; - const workers: Promise[] = []; - const worker = async (): Promise => { - while (index < coll.length) { - const currentIndex = index; - index++; - newColl[currentIndex] = await mapper(coll[currentIndex]); - } - }; - for (let i = 0; i < concurrency; i++) { - workers.push(worker()); - } - await Promise.all(workers); + return await pLimit(options.concurrency).map(coll, mapper); } - return newColl; } /** @@ -171,24 +156,7 @@ export async function asyncfilter( } } } else { - const concurrency = options.concurrency; - if (!Number.isInteger(concurrency) || concurrency < 1) { - throw new Error('Concurrency option must be a positive integer'); - } - let index = 0; - const bools: boolean[] = new Array(coll.length); - const workers: Promise[] = []; - const worker = async (): Promise => { - while (index < coll.length) { - const currentIndex = index; - index++; - bools[currentIndex] = await filter(coll[currentIndex]); - } - }; - for (let i = 0; i < concurrency; i++) { - workers.push(worker()); - } - await Promise.all(workers); + const bools = await pLimit(options.concurrency).map(coll, filter); for (let i = 0; i < coll.length; i++) { if (bools[i]) { newColl.push(coll[i]); diff --git a/package.json b/package.json index a649318..5aa42d7 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,9 @@ "printWidth": 100, "singleQuote": true }, + "dependencies": { + "p-limit": "^7.2.0" + }, "devDependencies": { "@appium/eslint-config-appium-ts": "^2.0.5", "@appium/tsconfig": "^1.0.0", @@ -56,8 +59,8 @@ "chai-as-promised": "^8.0.2", "conventional-changelog-conventionalcommits": "^9.0.0", "eslint": "^9.39.1", - "prettier": "^3.0.0", "mocha": "^11.7.5", + "prettier": "^3.0.0", "semantic-release": "^25.0.2", "ts-node": "^10.9.1", "tsx": "^4.21.0", diff --git a/test/asyncbox-specs.ts b/test/asyncbox-specs.ts index 65f4b9e..70cccd1 100644 --- a/test/asyncbox-specs.ts +++ b/test/asyncbox-specs.ts @@ -241,7 +241,7 @@ describe('asyncmap', function () { ].forEach(({desc, val}) => { it(`should raise an error for ${desc} concurrency option value`, async function () { await expect(asyncmap(coll, mapper, {concurrency: val})).to.be.rejectedWith( - 'Concurrency option must be a positive integer' + 'Expected `concurrency` to be a number from 1 and up' ); }); }); @@ -283,7 +283,7 @@ describe('asyncfilter', function () { ].forEach(({desc, val}) => { it(`should raise an error for ${desc} concurrency option value`, async function () { await expect(asyncfilter(coll, filter, {concurrency: val})).to.be.rejectedWith( - 'Concurrency option must be a positive integer' + 'Expected `concurrency` to be a number from 1 and up' ); }); }); From 01fa1a2d53c9e2f42458aeecd28eb9386f2e0de5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Egl=C4=ABtis?= Date: Mon, 26 Jan 2026 13:13:06 +0200 Subject: [PATCH 6/9] simplify with limitFunction --- lib/asyncbox.ts | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/lib/asyncbox.ts b/lib/asyncbox.ts index 368df79..4d6f3d4 100644 --- a/lib/asyncbox.ts +++ b/lib/asyncbox.ts @@ -1,4 +1,4 @@ -import pLimit from 'p-limit'; +import {limitFunction} from 'p-limit'; import type {LongSleepOptions, MapFilterOptions, WaitForConditionOptions} from './types.js'; const LONG_SLEEP_THRESHOLD = 5000; // anything over 5000ms will turn into a spin @@ -113,21 +113,19 @@ export async function retryInterval( */ export async function asyncmap( coll: T[], - mapper: (value: T) => R | Promise, + mapper: (value: T) => PromiseLike, options: MapFilterOptions = true, ): Promise { - if (options === true) { - return Promise.all(coll.map(mapper)); - } if (options === false) { const newColl: R[] = []; for (const item of coll) { newColl.push(await mapper(item)); } return newColl; - } else { - return await pLimit(options.concurrency).map(coll, mapper); } + const adjustedMapper = + options === true ? mapper : limitFunction(mapper, {concurrency: options.concurrency}); + return Promise.all(coll.map(adjustedMapper)); } /** @@ -138,25 +136,20 @@ export async function asyncmap( */ export async function asyncfilter( coll: T[], - filter: (value: T) => boolean | Promise, + filter: (value: T) => PromiseLike, options: MapFilterOptions = true, ): Promise { const newColl: T[] = []; - if (options === true) { - const bools = await Promise.all(coll.map(filter)); - for (let i = 0; i < coll.length; i++) { - if (bools[i]) { - newColl.push(coll[i]); - } - } - } else if (options === false) { + if (options === false) { for (const item of coll) { if (await filter(item)) { newColl.push(item); } } } else { - const bools = await pLimit(options.concurrency).map(coll, filter); + const adjustedFilter = + options === true ? filter : limitFunction(filter, {concurrency: options.concurrency}); + const bools = await Promise.all(coll.map(adjustedFilter)); for (let i = 0; i < coll.length; i++) { if (bools[i]) { newColl.push(coll[i]); From 84e745a75886668ef1559cf835af64e872f076df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Egl=C4=ABtis?= Date: Mon, 26 Jan 2026 19:36:24 +0200 Subject: [PATCH 7/9] address review comments --- lib/asyncbox.ts | 26 +++++++++++++------------- test/asyncbox-specs.ts | 22 ---------------------- 2 files changed, 13 insertions(+), 35 deletions(-) diff --git a/lib/asyncbox.ts b/lib/asyncbox.ts index 4d6f3d4..c53ae3d 100644 --- a/lib/asyncbox.ts +++ b/lib/asyncbox.ts @@ -117,11 +117,10 @@ export async function asyncmap( options: MapFilterOptions = true, ): Promise { if (options === false) { - const newColl: R[] = []; - for (const item of coll) { - newColl.push(await mapper(item)); - } - return newColl; + return coll.reduce>( + async (acc, item) => [...(await acc), await mapper(item)], + Promise.resolve([]), + ); } const adjustedMapper = options === true ? mapper : limitFunction(mapper, {concurrency: options.concurrency}); @@ -139,24 +138,25 @@ export async function asyncfilter( filter: (value: T) => PromiseLike, options: MapFilterOptions = true, ): Promise { - const newColl: T[] = []; if (options === false) { - for (const item of coll) { + return coll.reduce>(async (accP, item) => { + const acc = await accP; if (await filter(item)) { - newColl.push(item); + acc.push(item); } - } + return acc; + }, Promise.resolve([])); } else { const adjustedFilter = options === true ? filter : limitFunction(filter, {concurrency: options.concurrency}); const bools = await Promise.all(coll.map(adjustedFilter)); - for (let i = 0; i < coll.length; i++) { + return coll.reduce((acc, item, i) => { if (bools[i]) { - newColl.push(coll[i]); + acc.push(item); } - } + return acc; + }, []); } - return newColl; } /** diff --git a/test/asyncbox-specs.ts b/test/asyncbox-specs.ts index 70cccd1..0eacc72 100644 --- a/test/asyncbox-specs.ts +++ b/test/asyncbox-specs.ts @@ -234,17 +234,6 @@ describe('asyncmap', function () { it('should handle an empty array in parallel', async function () { expect(await asyncmap([], mapper)).to.eql([]); }); - [ - {desc: 'a zero', val: 0}, - {desc: 'a negative', val: -1}, - {desc: 'a non-integer', val: 2.5}, - ].forEach(({desc, val}) => { - it(`should raise an error for ${desc} concurrency option value`, async function () { - await expect(asyncmap(coll, mapper, {concurrency: val})).to.be.rejectedWith( - 'Expected `concurrency` to be a number from 1 and up' - ); - }); - }); }); describe('asyncfilter', function () { @@ -276,15 +265,4 @@ describe('asyncfilter', function () { it('should handle an empty array in parallel', async function () { expect(await asyncfilter([], filter)).to.eql([]); }); - [ - {desc: 'a zero', val: 0}, - {desc: 'a negative', val: -1}, - {desc: 'a non-integer', val: 2.5}, - ].forEach(({desc, val}) => { - it(`should raise an error for ${desc} concurrency option value`, async function () { - await expect(asyncfilter(coll, filter, {concurrency: val})).to.be.rejectedWith( - 'Expected `concurrency` to be a number from 1 and up' - ); - }); - }); }); From aae3b10574a8eef39386dba4005dd7eaaf0ab8e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Egl=C4=ABtis?= Date: Tue, 27 Jan 2026 00:20:11 +0200 Subject: [PATCH 8/9] address review comments --- lib/asyncbox.ts | 25 +++++++++++++++---------- test/asyncbox-specs.ts | 12 ++++++++++++ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/lib/asyncbox.ts b/lib/asyncbox.ts index c53ae3d..5e341bf 100644 --- a/lib/asyncbox.ts +++ b/lib/asyncbox.ts @@ -116,6 +116,9 @@ export async function asyncmap( mapper: (value: T) => PromiseLike, options: MapFilterOptions = true, ): Promise { + if (options === null) { + throw new Error('Options cannot be null'); + } if (options === false) { return coll.reduce>( async (acc, item) => [...(await acc), await mapper(item)], @@ -138,6 +141,9 @@ export async function asyncfilter( filter: (value: T) => PromiseLike, options: MapFilterOptions = true, ): Promise { + if (options === null) { + throw new Error('Options cannot be null'); + } if (options === false) { return coll.reduce>(async (accP, item) => { const acc = await accP; @@ -146,17 +152,16 @@ export async function asyncfilter( } return acc; }, Promise.resolve([])); - } else { - const adjustedFilter = - options === true ? filter : limitFunction(filter, {concurrency: options.concurrency}); - const bools = await Promise.all(coll.map(adjustedFilter)); - return coll.reduce((acc, item, i) => { - if (bools[i]) { - acc.push(item); - } - return acc; - }, []); } + const adjustedFilter = + options === true ? filter : limitFunction(filter, {concurrency: options.concurrency}); + const bools = await Promise.all(coll.map(adjustedFilter)); + return coll.reduce((acc, item, i) => { + if (bools[i]) { + acc.push(item); + } + return acc; + }, []); } /** diff --git a/test/asyncbox-specs.ts b/test/asyncbox-specs.ts index 0eacc72..f712b45 100644 --- a/test/asyncbox-specs.ts +++ b/test/asyncbox-specs.ts @@ -234,6 +234,12 @@ describe('asyncmap', function () { it('should handle an empty array in parallel', async function () { expect(await asyncmap([], mapper)).to.eql([]); }); + it('should raise an error if options is null', async function () { + // @ts-expect-error - testing invalid inputs + await expect(asyncmap(coll, mapper, null)).to.be.rejectedWith( + 'Options cannot be null' + ); + }); }); describe('asyncfilter', function () { @@ -265,4 +271,10 @@ describe('asyncfilter', function () { it('should handle an empty array in parallel', async function () { expect(await asyncfilter([], filter)).to.eql([]); }); + it('should raise an error if options is null', async function () { + // @ts-expect-error - testing invalid inputs + await expect(asyncfilter(coll, filter, null)).to.be.rejectedWith( + 'Options cannot be null' + ); + }); }); From 1abd3f8ab7b6a101ecabb69e69c75c2045ee7abb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Egl=C4=ABtis?= Date: Tue, 27 Jan 2026 22:04:35 +0200 Subject: [PATCH 9/9] ensure it still works with sync inputs --- lib/asyncbox.ts | 16 ++++++++++------ test/asyncbox-specs.ts | 10 ++++++++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/lib/asyncbox.ts b/lib/asyncbox.ts index 5e341bf..7f1aaa6 100644 --- a/lib/asyncbox.ts +++ b/lib/asyncbox.ts @@ -113,20 +113,22 @@ export async function retryInterval( */ export async function asyncmap( coll: T[], - mapper: (value: T) => PromiseLike, + mapper: (value: T) => R | Promise, options: MapFilterOptions = true, ): Promise { if (options === null) { throw new Error('Options cannot be null'); } + // limitFunction requires the mapper to always return a promise + const mapperAsync = async (value: T): Promise => mapper(value); if (options === false) { return coll.reduce>( - async (acc, item) => [...(await acc), await mapper(item)], + async (acc, item) => [...(await acc), await mapperAsync(item)], Promise.resolve([]), ); } const adjustedMapper = - options === true ? mapper : limitFunction(mapper, {concurrency: options.concurrency}); + options === true ? mapperAsync : limitFunction(mapperAsync, {concurrency: options.concurrency}); return Promise.all(coll.map(adjustedMapper)); } @@ -138,23 +140,25 @@ export async function asyncmap( */ export async function asyncfilter( coll: T[], - filter: (value: T) => PromiseLike, + filter: (value: T) => boolean | Promise, options: MapFilterOptions = true, ): Promise { if (options === null) { throw new Error('Options cannot be null'); } + // limitFunction requires the filter to always return a promise + const filterAsync = async (value: T): Promise => filter(value); if (options === false) { return coll.reduce>(async (accP, item) => { const acc = await accP; - if (await filter(item)) { + if (await filterAsync(item)) { acc.push(item); } return acc; }, Promise.resolve([])); } const adjustedFilter = - options === true ? filter : limitFunction(filter, {concurrency: options.concurrency}); + options === true ? filterAsync : limitFunction(filterAsync, {concurrency: options.concurrency}); const bools = await Promise.all(coll.map(adjustedFilter)); return coll.reduce((acc, item, i) => { if (bools[i]) { diff --git a/test/asyncbox-specs.ts b/test/asyncbox-specs.ts index f712b45..d336124 100644 --- a/test/asyncbox-specs.ts +++ b/test/asyncbox-specs.ts @@ -234,6 +234,11 @@ describe('asyncmap', function () { it('should handle an empty array in parallel', async function () { expect(await asyncmap([], mapper)).to.eql([]); }); + it('should work for a sync mapper function', async function () { + const syncmapper = (el: number): number => el * 2; + expect(await asyncmap(coll, syncmapper, false)).to.eql(newColl); + expect(await asyncmap(coll, syncmapper)).to.eql(newColl); + }); it('should raise an error if options is null', async function () { // @ts-expect-error - testing invalid inputs await expect(asyncmap(coll, mapper, null)).to.be.rejectedWith( @@ -271,6 +276,11 @@ describe('asyncfilter', function () { it('should handle an empty array in parallel', async function () { expect(await asyncfilter([], filter)).to.eql([]); }); + it('should work for a sync filter function', async function () { + const syncfilter = (el: number): boolean => el % 2 === 0; + expect(await asyncfilter(coll, syncfilter, false)).to.eql(newColl); + expect(await asyncfilter(coll, syncfilter)).to.eql(newColl); + }); it('should raise an error if options is null', async function () { // @ts-expect-error - testing invalid inputs await expect(asyncfilter(coll, filter, null)).to.be.rejectedWith(