feat(stdlib): new modules + eslint/tsconfig migration

- Add array/async/etc. modules and type tests; migrate to eslint flat config
  and composite tsconfig (vitest typecheck enabled).
- Fix PubSub.emit to snapshot listeners before iterating (stable EventEmitter
  semantics; avoids invoking listeners added during the same emit).
This commit is contained in:
2026-06-07 16:29:08 +07:00
parent 008d85a8fd
commit 96f4cba4a8
118 changed files with 3511 additions and 240 deletions
+10 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
import { cluster } from '.';
describe('cluster', () => {
@@ -37,4 +37,13 @@ describe('cluster', () => {
expect(result).toEqual([]);
});
it('not mutate the input and produce copied sub-arrays', () => {
const input = [1, 2, 3, 4];
const result = cluster(input, 2);
result[0]!.push(99);
expect(input).toEqual([1, 2, 3, 4]);
});
});
+8 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
import { first } from '.';
describe('first', () => {
@@ -20,4 +20,11 @@ describe('first', () => {
expect(first([1, 2, 3], 42)).toBe(1);
expect(first(['a', 'b', 'c'], 'default')).toBe('a');
});
it('preserve a present null/undefined/falsy first element (not the default)', () => {
expect(first([null as number | null], 42)).toBeNull();
expect(first([undefined, 2], 42)).toBeUndefined();
expect(first([0], 99)).toBe(0);
expect(first([''], 'x')).toBe('');
});
});
+2 -1
View File
@@ -16,5 +16,6 @@
* @since 0.0.3
*/
export function first<Value>(arr: Value[], defaultValue?: Value) {
return arr[0] ?? defaultValue;
// Branch on length, not nullishness, so a present null/undefined first element is preserved.
return arr.length > 0 ? arr[0]! : defaultValue;
}
@@ -0,0 +1,16 @@
import { describe, expectTypeOf, it } from 'vitest';
import { groupBy } from '.';
describe('groupBy', () => {
it('keys the record by the union returned by the key function', () => {
const result = groupBy([1, 2, 3], n => (n % 2 === 0 ? 'even' : 'odd'));
expectTypeOf(result).toEqualTypeOf<Record<'even' | 'odd', number[]>>();
});
it('preserves the element type in the grouped arrays', () => {
const result = groupBy([{ id: 1 }], item => item.id);
expectTypeOf(result).toEqualTypeOf<Record<number, Array<{ id: number }>>>();
});
});
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { groupBy } from '.';
describe('groupBy', () => {
it('group by a string key', () => {
const result = groupBy([1, 2, 3, 4], n => (n % 2 === 0 ? 'even' : 'odd'));
expect(result).toEqual({ odd: [1, 3], even: [2, 4] });
});
it('group objects by a property', () => {
const input = [
{ type: 'a', v: 1 },
{ type: 'b', v: 2 },
{ type: 'a', v: 3 },
];
expect(groupBy(input, item => item.type)).toEqual({
a: [{ type: 'a', v: 1 }, { type: 'a', v: 3 }],
b: [{ type: 'b', v: 2 }],
});
});
it('pass the index to the key function', () => {
const result = groupBy(['a', 'b', 'c', 'd'], (_, i) => i % 2);
expect(result).toEqual({ 0: ['a', 'c'], 1: ['b', 'd'] });
});
it('return an empty object for an empty array', () => {
expect(groupBy([], () => 'x')).toEqual({});
});
it('push elements by reference (no cloning)', () => {
const item = { id: 1 };
const result = groupBy([item], x => x.id);
expect(result[1]![0]).toBe(item);
});
it('handle __proto__ and other Object.prototype keys as ordinary groups', () => {
const proto = groupBy(['a', 'b'], (): string => '__proto__');
expect(proto.__proto__).toEqual(['a', 'b']);
const ctor = groupBy(['x'], (): string => 'constructor');
expect(ctor.constructor).toEqual(['x']);
});
});
+36
View File
@@ -0,0 +1,36 @@
/**
* @name groupBy
* @category Arrays
* @description Groups the elements of an array by the key returned by `getKey`
*
* @param {Value[]} array - The array to group
* @param {(item: Value, index: number) => Key} getKey - Maps an element to its group key
* @returns {Record<Key, Value[]>} An object of arrays, keyed by group
*
* @example
* groupBy([1, 2, 3, 4], n => (n % 2 === 0 ? 'even' : 'odd'))
* // => { odd: [1, 3], even: [2, 4] }
*
* @example
* groupBy([{ type: 'a', v: 1 }, { type: 'b', v: 2 }, { type: 'a', v: 3 }], item => item.type)
* // => { a: [{ type: 'a', v: 1 }, { type: 'a', v: 3 }], b: [{ type: 'b', v: 2 }] }
*
* @since 0.0.10
*/
export function groupBy<Value, Key extends PropertyKey>(
array: Value[],
getKey: (item: Value, index: number) => Key,
): Record<Key, Value[]> {
// Null-prototype object so keys like '__proto__'/'constructor' become ordinary own keys
// instead of colliding with Object.prototype (which would throw on .push or pollute).
const result = Object.create(null) as Record<Key, Value[]>;
for (let i = 0; i < array.length; i++) {
const item = array[i]!;
const key = getKey(item, i);
(result[key] ??= []).push(item);
}
return result;
}
+5
View File
@@ -1,5 +1,10 @@
export * from './cluster';
export * from './first';
export * from './groupBy';
export * from './last';
export * from './partition';
export * from './range';
export * from './sum';
export * from './toArray';
export * from './unique';
export * from './zip';
+7 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
import { last } from '.';
describe('last', () => {
@@ -20,4 +20,10 @@ describe('last', () => {
expect(last([1, 2, 3], 42)).toBe(3);
expect(last(['a', 'b', 'c'], 'default')).toBe('c');
});
it('preserve a present null/undefined/falsy last element (not the default)', () => {
expect(last([1, null as number | null], 42)).toBeNull();
expect(last([1, undefined], 42)).toBeUndefined();
expect(last([0], 99)).toBe(0);
});
});
+2 -1
View File
@@ -16,5 +16,6 @@
* @since 0.0.3
*/
export function last<Value>(arr: Value[], defaultValue?: Value) {
return arr[arr.length - 1] ?? defaultValue;
// Branch on length, not nullishness, so a present null/undefined last element is preserved.
return arr.length > 0 ? arr[arr.length - 1]! : defaultValue;
}
@@ -0,0 +1,17 @@
import { describe, expectTypeOf, it } from 'vitest';
import { partition } from '.';
describe('partition', () => {
it('returns a tuple of two arrays of the element type', () => {
const result = partition([1, 2, 3], n => n > 1);
expectTypeOf(result).toEqualTypeOf<[number[], number[]]>();
});
it('narrows both partitions with a type guard', () => {
const mixed: Array<string | number> = ['a', 1];
const result = partition(mixed, (v): v is string => typeof v === 'string');
expectTypeOf(result).toEqualTypeOf<[string[], number[]]>();
});
});
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { partition } from '.';
describe('partition', () => {
it('split by a predicate into [matching, rest]', () => {
expect(partition([1, 2, 3, 4], n => n % 2 === 0)).toEqual([[2, 4], [1, 3]]);
});
it('preserve order within each partition', () => {
expect(partition([5, 1, 4, 2, 3], n => n > 2)).toEqual([[5, 4, 3], [1, 2]]);
});
it('pass the index to the predicate', () => {
expect(partition(['a', 'b', 'c', 'd'], (_, i) => i < 2)).toEqual([['a', 'b'], ['c', 'd']]);
});
it('handle all-matching and none-matching', () => {
expect(partition([1, 2, 3], () => true)).toEqual([[1, 2, 3], []]);
expect(partition([1, 2, 3], () => false)).toEqual([[], [1, 2, 3]]);
});
it('work with a type guard', () => {
const mixed: Array<string | number> = ['a', 1, 'b', 2];
const [strings, numbers] = partition(mixed, (v): v is string => typeof v === 'string');
expect(strings).toEqual(['a', 'b']);
expect(numbers).toEqual([1, 2]);
});
});
+43
View File
@@ -0,0 +1,43 @@
/**
* @name partition
* @category Arrays
* @description Splits an array into two: elements that satisfy the predicate and those that do not
*
* @param {Value[]} array - The array to split
* @param {(item: Value, index: number) => boolean} predicate - Decides which partition an element belongs to
* @returns {[Value[], Value[]]} A tuple of `[matching, rest]`
*
* @example
* partition([1, 2, 3, 4], n => n % 2 === 0) // => [[2, 4], [1, 3]]
*
* @example
* const [strings, others] = partition(mixed, (v): v is string => typeof v === 'string');
*
* @since 0.0.10
*/
export function partition<Value, Matched extends Value>(
array: Value[],
predicate: (item: Value, index: number) => item is Matched,
): [Matched[], Array<Exclude<Value, Matched>>];
export function partition<Value>(
array: Value[],
predicate: (item: Value, index: number) => boolean,
): [Value[], Value[]];
export function partition<Value>(
array: Value[],
predicate: (item: Value, index: number) => boolean,
): [Value[], Value[]] {
const matched: Value[] = [];
const rest: Value[] = [];
for (let i = 0; i < array.length; i++) {
const item = array[i]!;
if (predicate(item, i))
matched.push(item);
else
rest.push(item);
}
return [matched, rest];
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { range } from '.';
describe('range', () => {
it('generate 0..stop with a single argument', () => {
expect(range(4)).toEqual([0, 1, 2, 3]);
});
it('generate start..stop', () => {
expect(range(1, 5)).toEqual([1, 2, 3, 4]);
});
it('respect a positive step', () => {
expect(range(0, 10, 2)).toEqual([0, 2, 4, 6, 8]);
});
it('support a negative step', () => {
expect(range(5, 0, -1)).toEqual([5, 4, 3, 2, 1]);
});
it('return an empty array for an empty range', () => {
expect(range(0)).toEqual([]);
expect(range(5, 5)).toEqual([]);
expect(range(0, 5, -1)).toEqual([]);
});
it('return an empty array for a zero step', () => {
expect(range(0, 5, 0)).toEqual([]);
});
it('handle non-integer steps', () => {
expect(range(0, 1, 0.25)).toEqual([0, 0.25, 0.5, 0.75]);
});
it('span zero with a negative start', () => {
expect(range(-2, 3)).toEqual([-2, -1, 0, 1, 2]);
expect(range(-3, 3, 2)).toEqual([-3, -1, 1]);
});
it('handle a non-integer step that is not exactly representable', () => {
const result = range(0, 1, 0.1);
expect(result).toHaveLength(10);
expect(result[0]).toBe(0);
expect(result.at(-1)).toBeCloseTo(0.9, 10);
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* @name range
* @category Arrays
* @description Generates an array of numbers from `start` (inclusive) to `stop` (exclusive)
*
* @param {number} startOrStop - The start of the range, or the stop when called with one argument
* @param {number} [stop] - The end of the range (exclusive)
* @param {number} [step] - The increment between values; supports negative steps. Default `1`
* @returns {number[]} The generated range
*
* @example
* range(4) // => [0, 1, 2, 3]
*
* @example
* range(1, 5) // => [1, 2, 3, 4]
*
* @example
* range(0, 10, 2) // => [0, 2, 4, 6, 8]
*
* @example
* range(5, 0, -1) // => [5, 4, 3, 2, 1]
*
* @since 0.0.10
*/
export function range(stop: number): number[];
export function range(start: number, stop: number, step?: number): number[];
export function range(startOrStop: number, stop?: number, step = 1): number[] {
let start = startOrStop;
if (stop === undefined) {
start = 0;
stop = startOrStop;
}
if (step === 0)
return [];
const length = Math.max(Math.ceil((stop - start) / step), 0);
return Array.from({ length }, (_, i) => start + i * step);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
import { sum } from '.';
describe('sum', () => {
@@ -0,0 +1,17 @@
import { describe, expectTypeOf, it } from 'vitest';
import { toArray } from '.';
describe('toArray', () => {
it('wraps a single value into an array of that type', () => {
expectTypeOf(toArray(1)).toEqualTypeOf<number[]>();
});
it('returns an array input as the same element type', () => {
expectTypeOf(toArray([1, 2, 3])).toEqualTypeOf<number[]>();
});
it('returns the element array type for a nullish input', () => {
expectTypeOf(toArray<string>(undefined)).toEqualTypeOf<string[]>();
expectTypeOf(toArray<number>(null)).toEqualTypeOf<number[]>();
});
});
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { toArray } from '.';
describe('toArray', () => {
it('wrap a single value into an array', () => {
expect(toArray(1)).toEqual([1]);
expect(toArray('a')).toEqual(['a']);
expect(toArray(false)).toEqual([false]);
expect(toArray(0)).toEqual([0]);
});
it('return arrays as-is (same reference, no copy)', () => {
const arr = [1, 2, 3];
expect(toArray(arr)).toBe(arr);
});
it('treat null and undefined as empty', () => {
expect(toArray(undefined)).toEqual([]);
expect(toArray(null)).toEqual([]);
});
it('preserve empty arrays', () => {
const empty: number[] = [];
expect(toArray(empty)).toBe(empty);
});
});
+25
View File
@@ -0,0 +1,25 @@
import type { Arrayable } from '../../types';
/**
* @name toArray
* @category Arrays
* @description Normalize an `Arrayable<T>` value into an array. `undefined` and `null` become an empty array; a single value is wrapped; arrays are returned as-is (no copy).
*
* @param {Arrayable<Value> | null | undefined} value The value to normalize
* @returns {Value[]} The value as an array
*
* @example
* toArray(1) // => [1]
*
* @example
* toArray([1, 2]) // => [1, 2]
*
* @example
* toArray(undefined) // => []
*
* @since 0.0.10
*/
export function toArray<Value>(value: Arrayable<Value> | null | undefined): Value[] {
if (value === null || value === undefined) return [];
return Array.isArray(value) ? value : [value];
}
+25 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
import { unique } from '.';
describe('unique', () => {
@@ -42,4 +42,28 @@ describe('unique', () => {
expect(result).toEqual([]);
});
it('keep the last value per extracted key (last-write-wins, first-seen order)', () => {
const result = unique(
[{ id: 1, v: 'a' }, { id: 2, v: 'b' }, { id: 1, v: 'c' }],
item => item.id,
);
expect(result).toEqual([{ id: 1, v: 'c' }, { id: 2, v: 'b' }]);
});
it('return a new array and not mutate the input', () => {
const input = [1, 2, 2, 3];
const result = unique(input);
expect(result).not.toBe(input);
expect(input).toEqual([1, 2, 2, 3]);
});
it('preserve element identity for object values', () => {
const a = { id: 1 };
const result = unique([a], item => item.id);
expect(result[0]).toBe(a);
});
});
+10 -7
View File
@@ -14,20 +14,23 @@ export type Extractor<Value, Key extends UniqueKey> = (value: Value) => Key;
* unique([1, 2, 3, 3, 4, 5, 5, 6]) //=> [1, 2, 3, 4, 5, 6]
*
* @example
* unique([{ id: 1 }, { id: 2 }, { id: 1 }], (a, b) => a.id === b.id) //=> [{ id: 1 }, { id: 2 }]
* unique([{ id: 1 }, { id: 2 }, { id: 1 }], value => value.id) //=> [{ id: 1 }, { id: 2 }]
*
* @since 0.0.3
*/
export function unique<Value, Key extends UniqueKey>(
array: Value[],
extractor?: Extractor<Value, Key>,
) {
): Value[] {
// Fast path: a plain Set is leaner than a Map storing each value as both key and value.
if (!extractor)
return [...new Set(array)];
// Last-write-wins per extracted key, preserving first-seen insertion order.
const values = new Map<Key, Value>();
for (const value of array) {
const key = extractor ? extractor(value) : value as any;
values.set(key, value);
}
for (const value of array)
values.set(extractor(value), value);
return Array.from(values.values());
return [...values.values()];
}
@@ -0,0 +1,27 @@
import { describe, expectTypeOf, it } from 'vitest';
import { zip } from '.';
describe('zip', () => {
it('produces tuples of the element types (two arrays)', () => {
const result = zip([1, 2], ['a', 'b']);
expectTypeOf(result).toEqualTypeOf<Array<[number, string]>>();
});
it('produces tuples of the element types (three arrays)', () => {
const result = zip([1], ['a'], [true]);
expectTypeOf(result).toEqualTypeOf<Array<[number, string, boolean]>>();
});
it('zips a single array into singleton tuples', () => {
const result = zip([1, 2, 3]);
expectTypeOf(result).toEqualTypeOf<Array<[number]>>();
});
it('produces tuples for four and five arrays', () => {
expectTypeOf(zip([1], ['a'], [true], [9])).toEqualTypeOf<Array<[number, string, boolean, number]>>();
expectTypeOf(zip([1], ['a'], [true], [9], ['x'])).toEqualTypeOf<Array<[number, string, boolean, number, string]>>();
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { zip } from '.';
describe('zip', () => {
it('zip two arrays of equal length', () => {
expect(zip([1, 2, 3], ['a', 'b', 'c'])).toEqual([[1, 'a'], [2, 'b'], [3, 'c']]);
});
it('zip three arrays', () => {
expect(zip([1, 2], ['a', 'b'], [true, false])).toEqual([[1, 'a', true], [2, 'b', false]]);
});
it('truncate to the shortest array', () => {
expect(zip([1, 2, 3], ['a'])).toEqual([[1, 'a']]);
expect(zip([1], ['a', 'b', 'c'])).toEqual([[1, 'a']]);
});
it('zip a single array into singletons', () => {
expect(zip([1, 2, 3])).toEqual([[1], [2], [3]]);
});
it('return an empty array when an input is empty', () => {
expect(zip([1, 2, 3], [])).toEqual([]);
});
it('return an empty array with no arguments', () => {
expect((zip as () => unknown[])()).toEqual([]);
});
it('zip four and five arrays', () => {
expect(zip([1], ['a'], [true], [9])).toEqual([[1, 'a', true, 9]]);
expect(zip([1, 2], ['a', 'b'], [true, false], [9, 8], ['x', 'y']))
.toEqual([[1, 'a', true, 9, 'x'], [2, 'b', false, 8, 'y']]);
});
});
+35
View File
@@ -0,0 +1,35 @@
/**
* @name zip
* @category Arrays
* @description Combines several arrays into an array of tuples, stopping at the shortest input
*
* @param {...Array} arrays - The arrays to zip together
* @returns {Array} An array of tuples; its length equals the shortest input array
*
* @example
* zip([1, 2, 3], ['a', 'b', 'c']) // => [[1, 'a'], [2, 'b'], [3, 'c']]
*
* @example
* zip([1, 2], ['a', 'b'], [true, false]) // => [[1, 'a', true], [2, 'b', false]]
*
* @example
* zip([1, 2, 3], ['a']) // => [[1, 'a']] (truncated to the shortest)
*
* @since 0.0.10
*/
export function zip<A>(a: A[]): Array<[A]>;
export function zip<A, B>(a: A[], b: B[]): Array<[A, B]>;
export function zip<A, B, C>(a: A[], b: B[], c: C[]): Array<[A, B, C]>;
export function zip<A, B, C, D>(a: A[], b: B[], c: C[], d: D[]): Array<[A, B, C, D]>;
export function zip<A, B, C, D, E>(a: A[], b: B[], c: C[], d: D[], e: E[]): Array<[A, B, C, D, E]>;
export function zip(...arrays: any[][]): any[][] {
if (arrays.length === 0)
return [];
let length = arrays[0]!.length;
for (let i = 1; i < arrays.length; i++)
length = Math.min(length, arrays[i]!.length);
return Array.from({ length }, (_, i) => arrays.map(array => array[i]));
}