diff --git a/.husky/pre-commit b/.husky/pre-commit index 98475b5..009b3f8 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -pnpm test +pnpm lint diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..98475b5 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1 @@ +pnpm test diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b7425b9 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +enable-pre-post-scripts=true \ No newline at end of file diff --git a/.xo-config.json b/.xo-config.json index 9e3acef..1a87094 100644 --- a/.xo-config.json +++ b/.xo-config.json @@ -1,6 +1,11 @@ { + "env": ["jest", "node"], "rules": { "import/extensions": "off", - "unicorn/prefer-module": "off" + "unicorn/prefer-module": "off", + "unicorn/no-array-for-each": "off", + "unicorn/prefer-top-level-await": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-assignment": "off" } } \ No newline at end of file diff --git a/README.md b/README.md index 253e921..f0dcb33 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,82 @@ # n-tuple-array -Get a configurable amount of items from a JS array, instead of only one item + +Get a specified amount of items when iterating over a JavaScript array, instead of the single item that arrays provide per iteration, by default. + + +## Motivation + +Imagine that you received a collection of coordinates (latitude and longitude), but they were sent +as a flat array of values to speed up the data transfers. + +`n-tuple-array` can help you get out the coordinates in pairs (their logical representation), such that you'd go **from** +```json +// flatCoords +["5.7225", "-9.6273", "2.68452", "-30.9501", ...] +``` + +**to** +```javascript +// generate pairs by default +const coordIterable = tuplesFromArray({ list: flatCoords }); + +// using for..of, get pairs as ["5.7225", "-9.6273"] ... +for (const pair of coordIterable) { + console.log(pair); +} + +// OR manipulate pairs with regular array functions +const coordPairs = Array.from(coordIterable); +console.log(Array.isArray(coordPairs)); // true +// prints ["5.7225", "-9.6273"] ... +coordPairs + .map(pair => myTransform(pair)) + .forEach((pair) => placeOnMap(pair)); +``` + +### Some Real World Examples +> I first had this idea and tried my hands on it when [building wole-joko](https://github.com/chalu/wole-joko/blob/dev/src/js/utils.js#L57-L92), a live coding task I was asked to do in an engineering manager interview. It was a simulation of people entering an event hall to get seated, but only two could get it/be attended to at a time. I later took some time to [bring the project to live](https://wole-joko.netlify.app/) + +> The below example was adapted for more concise terminal output + +
+ +JS challenge by @thdxr on X.com
+![](./assets/the-dax-js-challenge.png "JS challenge by @thdxr") +

+ +`n-tuple-array` solution code
+[](./assets/demo-classic.svg) +

+ +`n-tuple-array` n-tuple-array solution demo
+![](./assets/ntuple-array-demo-optimized.gif "n-tuple-array solution demo") + + + +## Setup & Usage + +```bash +npm install n-tuple-array +``` + +```javascript +import { tuplesFromArray } from 'n-tuple-array'; + +const numbers = Array.from({length: 100}, (_, i) => i + 1); +const isEven = (item) => { + if ( + !item + || typeof item !== 'number' + || item % 2 !== 0 + ) return false; + + return true; +}; + +for (const triplets of tuplesFromArray({list: numbers, maxItems: 3, match: isEven})) { + console.log(triplets); +} +``` + +See more examples in [src/demo](./src/demo/) + diff --git a/assets/demo-classic.png b/assets/demo-classic.png new file mode 100644 index 0000000..0a954ef Binary files /dev/null and b/assets/demo-classic.png differ diff --git a/assets/demo-classic.svg b/assets/demo-classic.svg new file mode 100644 index 0000000..ef5d726 --- /dev/null +++ b/assets/demo-classic.svg @@ -0,0 +1,224 @@ +
codesnap.dev
n-tuple-array-demo--parallel-process-max-3-items.ts
1const processItem = async <T>(item: T | undefined) => {
+2	await delay(Math.random() * 500); // Simulate some async workload
+3
+4	const processed = (item instanceof Date && isDate(item)) ? canadaDateFormat.format(item) : item;
+5	return processed;
+6};
+7
+8const parallelPool = async <T>(tasks: Array<T | undefined>, prarallelSize: number) => {
+9	const processed: Array<PromiseSettledResult<string | Awaited<T> | undefined>> = [];
+10	const parallelSizeAwareIterable = tuplesFromArray({
+11		list: tasks,
+12		maxItems: prarallelSize,
+13	});
+14
+15	let count = 1;
+16	for await (const cohort of parallelSizeAwareIterable) {
+17		const results = await Promise.allSettled(cohort.map(async itm => processItem(itm)));
+18		processed.push(...results);
+19
+20		console.log(`processed ${formatCount(count)} batch of <= ${prarallelSize} items`);
+21		if ((count * prarallelSize) >= tasks.length) {
+22			console.log('DONE!');
+23		}
+24
+25		count += 1;
+26	}
+27
+28	return processed;
+29};
+30
+31(async () => {
+32	const canadianDates = await parallelPool(dates(12), 3);
+33	console.log('-------------------');
+34	console.log(canadianDates);
+35})();
+36
+37// paste your code here
\ No newline at end of file diff --git a/assets/ntuple-array-demo-optimized.gif b/assets/ntuple-array-demo-optimized.gif new file mode 100644 index 0000000..7aba97e Binary files /dev/null and b/assets/ntuple-array-demo-optimized.gif differ diff --git a/assets/the-dax-js-challenge.png b/assets/the-dax-js-challenge.png new file mode 100644 index 0000000..41b9041 Binary files /dev/null and b/assets/the-dax-js-challenge.png differ diff --git a/dist/demo/demo-basics.js b/dist/demo/demo-basics.js new file mode 100644 index 0000000..6f01236 --- /dev/null +++ b/dist/demo/demo-basics.js @@ -0,0 +1,34 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const index_1 = require("../index"); +const demo_utils_1 = require("./demo-utils"); +console.log('----- Example 1 [10 Hex in twos. Used default param values] ------'); +for (const hexPair of (0, index_1.tuplesFromArray)({ list: (0, demo_utils_1.hexadecimals)(10) })) { + console.log(hexPair); +} +console.log('\n----- Example 2 [30 Hex in fives. Specified maxItems] ------'); +for (const hexQuintet of (0, index_1.tuplesFromArray)({ list: (0, demo_utils_1.hexadecimals)(30), maxItems: 5 })) { + console.log(hexQuintet); +} +console.log('\n----- Example 3 [Dates in twos. Filtered in by a match function] ------'); +// Create an array of 50 elements which include some dates +const data = [...(0, demo_utils_1.hexadecimals)(20), ...(0, demo_utils_1.dates)(10), ...(0, demo_utils_1.uuids)(20)]; +// Use a basic/weak shuffle algo to shuffle the array items +data.sort(() => Math.random() - 0.5); +for (const dateTriplet of (0, index_1.tuplesFromArray)({ list: data, match: demo_utils_1.isDate })) { + console.log(dateTriplet); +} +console.log('\n----- Example 4 [Tuples can be "remainders", see the last array/tuple] ------'); +for (const idTriplets of (0, index_1.tuplesFromArray)({ list: (0, demo_utils_1.uuids)(8), maxItems: 3 })) { + console.log(idTriplets); +} +const dozenHexValues = (0, demo_utils_1.hexadecimals)(12); +const hexIterable = (0, index_1.tuplesFromArray)({ list: dozenHexValues, maxItems: 2 }); +const hexPairs = Array.from(hexIterable); +console.log('Array.from( tuplesFromArray(...) ) is an Array:', Array.isArray(hexPairs)); +// Use native Array methods +hexPairs + .map(pair => pair.toString().toUpperCase().split(',')) + .forEach(pair => { + console.log(pair); +}); diff --git a/dist/demo/demo-classic.js b/dist/demo/demo-classic.js new file mode 100644 index 0000000..cbcab33 --- /dev/null +++ b/dist/demo/demo-classic.js @@ -0,0 +1,32 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const index_1 = require("../index"); +const demo_utils_1 = require("./demo-utils"); +const processItem = async (item) => { + await (0, demo_utils_1.delay)(Math.random() * 500); // Simulate some async workload + const processed = (item instanceof Date && (0, demo_utils_1.isDate)(item)) ? demo_utils_1.canadaDateFormat.format(item) : item; + return processed; +}; +const parallelPool = async (tasks, prarallelSize) => { + const processed = []; + const parallelSizeAwareIterable = (0, index_1.tuplesFromArray)({ + list: tasks, + maxItems: prarallelSize, + }); + let count = 1; + for await (const cohort of parallelSizeAwareIterable) { + const results = await Promise.allSettled(cohort.map(async (itm) => processItem(itm))); + processed.push(...results); + console.log(`processed ${(0, demo_utils_1.formatCount)(count)} batch of <= ${prarallelSize} items`); + if ((count * prarallelSize) >= tasks.length) { + console.log('DONE!'); + } + count += 1; + } + return processed; +}; +(async () => { + const canadianDates = await parallelPool((0, demo_utils_1.dates)(12), 3); + console.log('-------------------'); + console.log(canadianDates); +})(); diff --git a/dist/demo/demo-utils.js b/dist/demo/demo-utils.js new file mode 100644 index 0000000..ef25db0 --- /dev/null +++ b/dist/demo/demo-utils.js @@ -0,0 +1,36 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.delay = exports.formatCount = exports.canadaDateFormat = exports.isDate = exports.hexadecimals = exports.dates = exports.uuids = void 0; +const faker_1 = require("@faker-js/faker"); +/** + * Some Helper Functions + */ +const factory = (generator, howMany = 20) => faker_1.simpleFaker.helpers.multiple(generator, { count: howMany }); +const uuids = (howMany) => factory(faker_1.simpleFaker.string.uuid, howMany); +exports.uuids = uuids; +const dates = (howMany) => factory(faker_1.simpleFaker.date.birthdate, howMany); +exports.dates = dates; +const hexadecimals = (howMany) => factory(faker_1.simpleFaker.string.hexadecimal, howMany); +exports.hexadecimals = hexadecimals; +const isDate = (date) => (date instanceof Date) && !Number.isNaN(date.getTime()); +exports.isDate = isDate; +exports.canadaDateFormat = new Intl.DateTimeFormat('en-CA', { + dateStyle: 'medium', +}); +const plural = new Intl.PluralRules('en-US', { type: 'ordinal' }); +const suffixes = new Map([ + ['one', 'st'], + ['two', 'nd'], + ['few', 'rd'], + ['other', 'th'], +]); +const formatCount = (n) => { + const rule = plural.select(n); + const suffix = suffixes.get(rule); + return `${n}${suffix}`; +}; +exports.formatCount = formatCount; +const delay = async (ms) => new Promise(resolve => { + setTimeout(resolve, ms); +}); +exports.delay = delay; diff --git a/dist/index.d.ts b/dist/index.d.ts index b7469c1..aea6b25 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -1,8 +1,6 @@ -type IterationResult = { - done: boolean; - value: Array; -}; -type Matcher = (item: T | undefined) => boolean; +type Item = T | undefined; +type Value = Array>; +export type Matcher = (item: T | unknown) => boolean; export type TupleConfig = { list: T[]; maxItems?: number; @@ -10,8 +8,5 @@ export type TupleConfig = { }; export declare class InvalidInvocationParameterError extends Error { } -export declare const tuplesFromArray: (config: TupleConfig) => { - [Symbol.iterator](): any; - next(): IterationResult; -}; -export {}; +export declare const tuplesFromArray: (config: TupleConfig) => Iterable>; +export default tuplesFromArray; diff --git a/dist/index.js b/dist/index.js index 1c502e8..927cbf0 100644 --- a/dist/index.js +++ b/dist/index.js @@ -23,37 +23,40 @@ const tuplesFromArray = (config) => { validateParametersOrThrow(list, maxItems, match); let cursor = 0; const maxItemSize = Number.parseInt(`${maxItems}`, 10); - const iterable = { - [Symbol.iterator]() { - return this; - }, - next() { - if (cursor >= list.length) { - return { done: true, value: [] }; + const proceedNext = () => { + const items = []; + if (cursor >= list.length) { + return { done: true, value: [] }; + } + const endIndex = match === undefined + // A match funtion was provided. Okay to run to array end + // or until we've matched maxItemSize elements + ? Math.min(cursor + maxItemSize, list.length) + // No match function was provided. We should run till we are + // out of items (list.length) or till we gotten the next set + // of maxItemSize items + : list.length; + while (cursor < endIndex) { + const item = list[cursor]; + cursor += 1; + if (match && !match(item)) { + continue; } - const items = []; - const endIndex = match === undefined - // A match funtion was provided. Okay to run to array end - // or until we've matched maxItemSize elements - ? Math.min(cursor + maxItemSize, list.length) - // No match function was provided. We should run till we are - // out of items (list.length) or till we gotten the next set - // of maxItemSize items - : list.length; - while (cursor < endIndex) { - const item = list[cursor]; - cursor += 1; - if (match && !match(item)) { - continue; - } - items.push(item); - if (match && items.length === maxItemSize) { - break; - } + items.push(item); + if (match && items.length === maxItemSize) { + break; } - return { done: false, value: items }; + } + return { value: items, done: items.length === 0 }; + }; + const iterable = { + [Symbol.iterator]() { + return { + next: proceedNext, + }; }, }; return iterable; }; exports.tuplesFromArray = tuplesFromArray; +exports.default = exports.tuplesFromArray; diff --git a/package.json b/package.json index b7f8e40..d98121b 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,24 @@ { "name": "n-tuple-array", "version": "0.1", - "description": "Get a specified amount of items when iterating over an array, instead of only one item!", + "description": "Get a specified amount of items when iterating over a JavaScript array, instead of just a single item that native arrays provide!", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ "/dist" ], - "type": "module", "scripts": { "build": "tsc", - "lint": "xo --env=jest", - "lint:fix": "xo --env=jest --fix", - "test": "pnpm lint && pnpm build && jest --runInBand", + "prebuild": "rm -rf dist/*", + "postbuild": "rm -rf dist/demo/*.d.*", + "lint": "xo", + "lint:fix": "xo --fix", + "test": "jest --runInBand", + "pretest": "pnpm lint && pnpm build", "test:ci": "jest --ci --config='./jest.config.ci.ts'", "prepare": "husky" }, - "keywords": [], + "keywords": ["array", "tuple", "arrays", "tuples", "iterables", "iterators", "symbol.iterator", "javascript", "typescript"], "author": "Charles Odili ", "license": "MIT", "devDependencies": { diff --git a/src/demo/demo-basics.ts b/src/demo/demo-basics.ts new file mode 100644 index 0000000..e0c13df --- /dev/null +++ b/src/demo/demo-basics.ts @@ -0,0 +1,40 @@ +import {tuplesFromArray} from '../index'; +import { + hexadecimals, isDate, dates, uuids, +} from './demo-utils'; + +console.log('----- Example 1 [10 Hex in twos. Used default param values] ------'); +for (const hexPair of tuplesFromArray({list: hexadecimals(10)})) { + console.log(hexPair); +} + +console.log('\n----- Example 2 [30 Hex in fives. Specified maxItems] ------'); +for (const hexQuintet of tuplesFromArray({list: hexadecimals(30), maxItems: 5})) { + console.log(hexQuintet); +} + +console.log('\n----- Example 3 [Dates in twos. Filtered in by a match function] ------'); +// Create an array of 50 elements which include some dates +const data = [...hexadecimals(20), ...dates(10), ...uuids(20)] as Array; +// Use a basic/weak shuffle algo to shuffle the array items +data.sort(() => Math.random() - 0.5); + +for (const dateTriplet of tuplesFromArray({list: data, match: isDate})) { + console.log(dateTriplet); +} + +console.log('\n----- Example 4 [Tuples can be "remainders", see the last array/tuple] ------'); +for (const idTriplets of tuplesFromArray({list: uuids(8), maxItems: 3})) { + console.log(idTriplets); +} + +const dozenHexValues = hexadecimals(12); +const hexIterable = tuplesFromArray({list: dozenHexValues, maxItems: 2}); +const hexPairs = Array.from(hexIterable); +console.log('Array.from( tuplesFromArray(...) ) is an Array:', Array.isArray(hexPairs)); +// Use native Array methods +hexPairs + .map(pair => pair.toString().toUpperCase().split(',')) + .forEach(pair => { + console.log(pair); + }); diff --git a/src/demo/demo-classic.ts b/src/demo/demo-classic.ts new file mode 100644 index 0000000..e191584 --- /dev/null +++ b/src/demo/demo-classic.ts @@ -0,0 +1,41 @@ +import {tuplesFromArray} from '../index'; +import { + isDate, dates, canadaDateFormat, delay, formatCount, +} from './demo-utils'; + +const processItem = async (item: T | undefined) => { + await delay(Math.random() * 500); // Simulate some async workload + + const processed = (item instanceof Date && isDate(item)) ? canadaDateFormat.format(item) : item; + return processed; +}; + +const parallelPool = async (tasks: Array, prarallelSize: number) => { + const processed: Array | undefined>> = []; + const parallelSizeAwareIterable = tuplesFromArray({ + list: tasks, + maxItems: prarallelSize, + }); + + let count = 1; + for await (const cohort of parallelSizeAwareIterable) { + const results = await Promise.allSettled(cohort.map(async itm => processItem(itm))); + processed.push(...results); + + console.log(`processed ${formatCount(count)} batch of <= ${prarallelSize} items`); + if ((count * prarallelSize) >= tasks.length) { + console.log('DONE!'); + } + + count += 1; + } + + return processed; +}; + +(async () => { + const canadianDates = await parallelPool(dates(12), 3); + console.log('-------------------'); + console.log(canadianDates); +})(); + diff --git a/src/demo/demo-utils.ts b/src/demo/demo-utils.ts new file mode 100644 index 0000000..b218468 --- /dev/null +++ b/src/demo/demo-utils.ts @@ -0,0 +1,35 @@ +import {simpleFaker as Faker} from '@faker-js/faker'; + +/** + * Some Helper Functions + */ + +const factory = (generator: () => T, howMany = 20) => Faker.helpers.multiple(generator, {count: howMany}); + +export const uuids = (howMany?: number) => factory(Faker.string.uuid, howMany); +export const dates = (howMany?: number) => factory(Faker.date.birthdate, howMany); +export const hexadecimals = (howMany?: number) => factory(Faker.string.hexadecimal, howMany); + +export const isDate = (date: unknown) => (date instanceof Date) && !Number.isNaN(date.getTime()); + +export const canadaDateFormat = new Intl.DateTimeFormat('en-CA', { + dateStyle: 'medium', +}); + +const plural = new Intl.PluralRules('en-US', {type: 'ordinal'}); +const suffixes = new Map([ + ['one', 'st'], + ['two', 'nd'], + ['few', 'rd'], + ['other', 'th'], +]); + +export const formatCount = (n: number) => { + const rule = plural.select(n); + const suffix = suffixes.get(rule); + return `${n}${suffix}`; +}; + +export const delay = async (ms: number) => new Promise(resolve => { + setTimeout(resolve, ms); +}); diff --git a/src/index.ts b/src/index.ts index 88e1c72..6adea2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,8 @@ -type IterationResult = { - done: boolean; - value: Array; -}; +type Item = T | undefined; +type Value = Array>; +type Result = IteratorResult, Value>; -type Matcher = (item: T | undefined) => boolean; +export type Matcher = (item: T | unknown) => boolean; export type TupleConfig = { list: T[]; maxItems?: number; @@ -37,45 +36,51 @@ export const tuplesFromArray = (config: TupleConfig) => { let cursor = 0; const maxItemSize = Number.parseInt(`${maxItems}`, 10); - const iterable = { - [Symbol.iterator]() { - return this; - }, - next(): IterationResult { - if (cursor >= list.length) { - return {done: true, value: []}; - } + const proceedNext = (): Result => { + const items: Value = []; + + if (cursor >= list.length) { + return {done: true, value: []}; + } - const items: Array = []; - const endIndex = match === undefined + const endIndex = match === undefined // A match funtion was provided. Okay to run to array end // or until we've matched maxItemSize elements - ? Math.min(cursor + maxItemSize, list.length) + ? Math.min(cursor + maxItemSize, list.length) // No match function was provided. We should run till we are // out of items (list.length) or till we gotten the next set // of maxItemSize items - : list.length; + : list.length; - while (cursor < endIndex) { - const item = list[cursor]; - cursor += 1; + while (cursor < endIndex) { + const item = list[cursor]; + cursor += 1; - if (match && !match(item)) { - continue; - } + if (match && !match(item)) { + continue; + } - items.push(item); + items.push(item); - if (match && items.length === maxItemSize) { - break; - } + if (match && items.length === maxItemSize) { + break; } + } + + return {value: items, done: items.length === 0}; + }; - return {done: false, value: items}; + const iterable: Iterable> = { + [Symbol.iterator](): Iterator> { + return { + next: proceedNext, + }; }, }; return iterable; }; + +export default tuplesFromArray; diff --git a/tsconfig.json b/tsconfig.json index b1e938c..f86599f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { /* Visit https://aka.ms/tsconfig to read more about this file */ /* Language and Environment */ + "lib": ["ES2022", "dom"], "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ /* Control what method is used to detect module-format JS files. */ /* Modules */ "module": "commonjs", /* Specify what module code is generated. */ @@ -10,10 +11,12 @@ "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ "outDir": "./dist", /* Specify an output folder for all emitted files. */ "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ /* Ensure that casing is correct in imports. */ - "moduleResolution": "Node", + "moduleResolution": "node", + "resolveJsonModule": true, /* Type Checking */ "strict": true, /* Skip type checking all .d.ts files. */ - "noUncheckedIndexedAccess": true + "noUncheckedIndexedAccess": true, + "skipLibCheck": true }, "include": [ "src/**/*"