// effects: pure
/**
* Curried functional utilities for array operations and composition.
*
* Several utilities here are thin curried adapters over `@std/collections`,
* keeping the project's pipe-friendly calling convention while delegating the
* actual work to the standard library.
*/
import {
chunk as stdChunk,
distinct as stdDistinct,
distinctBy as stdDistinctBy,
mapNotNullish as stdMapNotNullish,
partition as stdPartition,
sumOf as stdSumOf,
} from "@std/collections";
// --- Pipe type helpers ---
//
// `pipe` threads intermediate types left-to-right so that an unannotated
// callback in a later stage (e.g. `map((x) => x.id)` after a `filter(...)`)
// infers its parameter type from the previous stage's output. This is achieved
// with arity-numbered overloads: TS threads A→B→C… across explicit parameters
// far more readily than across a generic tuple, and the per-stage contextual
// type lets the curried combinators (`map`, `filter`, `flatMap`, …) resolve
// their element type parameter from the boundary instead of collapsing to
// `unknown`. Omitting a variadic fallback is deliberate — a mismatched chain
// has no matching overload and fails to compile, which is the safety contract
// callers rely on. The largest chain in the repo today is 7 stages, and the
// overloads cover 9, so adding a stage means adding one overload line.
/**
* Compose functions left-to-right (pipe).
*/
export function pipe(): (a: A) => A;
export function pipe(ab: (a: A) => B): (a: A) => B;
export function pipe(ab: (a: A) => B, bc: (b: B) => C): (a: A) => C;
export function pipe(
ab: (a: A) => B,
bc: (b: B) => C,
cd: (c: C) => D,
): (a: A) => D;
export function pipe(
ab: (a: A) => B,
bc: (b: B) => C,
cd: (c: C) => D,
de: (d: D) => E,
): (a: A) => E;
export function pipe(
ab: (a: A) => B,
bc: (b: B) => C,
cd: (c: C) => D,
de: (d: D) => E,
ef: (e: E) => F,
): (a: A) => F;
export function pipe(
ab: (a: A) => B,
bc: (b: B) => C,
cd: (c: C) => D,
de: (d: D) => E,
ef: (e: E) => F,
fg: (f: F) => G,
): (a: A) => G;
export function pipe(
ab: (a: A) => B,
bc: (b: B) => C,
cd: (c: C) => D,
de: (d: D) => E,
ef: (e: E) => F,
fg: (f: F) => G,
gh: (g: G) => H,
): (a: A) => H;
export function pipe(
ab: (a: A) => B,
bc: (b: B) => C,
cd: (c: C) => D,
de: (d: D) => E,
ef: (e: E) => F,
fg: (f: F) => G,
gh: (g: G) => H,
hi: (h: H) => I,
): (a: A) => I;
export function pipe(
ab: (a: A) => B,
bc: (b: B) => C,
cd: (c: C) => D,
de: (d: D) => E,
ef: (e: E) => F,
fg: (f: F) => G,
gh: (g: G) => H,
hi: (h: H) => I,
ij: (i: I) => J,
): (a: A) => J;
export function pipe(
...fns: Array<(arg: unknown) => unknown>
): (value: unknown) => unknown {
return (value: unknown): unknown => fns.reduce((acc, fn) => fn(acc), value);
}
/**
* Curried filter
*/
export const filter =
(predicate: (item: T) => boolean) =>
(array: readonly T[]): T[] =>
array.filter(predicate);
/**
* Curried map
*/
export const map =
(fn: (item: T) => U) =>
(array: readonly T[]): U[] =>
array.map(fn);
/**
* Curried flatMap
*/
export const flatMap =
(fn: (item: T) => U[]) =>
(array: T[]): U[] =>
array.flatMap(fn);
/**
* Split an array into [matching, rest] by a predicate, keeping order.
* Curried adapter over `@std/collections.partition`.
*/
export const partition =
(predicate: (item: T) => boolean) =>
(array: T[]): [T[], T[]] =>
stdPartition(array, predicate);
/**
* Curried map that drops null/undefined results in one pass.
* Curried adapter over `@std/collections.mapNotNullish`.
* Replaces the two-step pattern: compact(map(fn)(array))
*/
export const mapNotNullish =
(fn: (item: T) => U | null | undefined) =>
(array: Iterable): U[] =>
stdMapNotNullish(array, fn);
/** Check items in order and stop at the first reported problem. */
export const firstProblem =
(check: (item: T) => string | null | Promise) =>
async (items: readonly T[]): Promise => {
for (const item of items) {
const problem = await check(item);
if (problem) return problem;
}
return null;
};
/**
* Curried reduce
*/
export const reduce =
(fn: (acc: U, item: T) => U, initial: U) =>
(array: T[]): U =>
array.reduce(fn, initial);
/**
* Non-mutating sort with comparator
*/
export const sort =
(comparator: (a: T, b: T) => number) =>
(array: T[]): T[] =>
array.toSorted(comparator);
/** Sort strings in ascending locale order without changing the input. */
export const sortStrings = (array: string[]): string[] =>
sort((a: string, b: string) => a.localeCompare(b))(array);
/** The numbers, each once, smallest first. */
export const sortedNumbers = (array: readonly number[]): number[] =>
unique([...array]).sort((a: number, b: number) => a - b);
/**
* Remove duplicate values (by reference/value equality), keeping first
* occurrences in order. Curried adapter over `@std/collections.distinct`.
*/
export const unique = (array: T[]): T[] => stdDistinct(array);
/**
* Remove duplicates by a key function, keeping first occurrences in order.
* Curried adapter over `@std/collections.distinctBy`.
*/
export const uniqueBy =
(fn: (item: T) => unknown) =>
(array: T[]): T[] =>
stdDistinctBy(array, fn);
/**
* Group rows by a key, keeping only the chosen value from each row.
* Keys appear in first-occurrence order and each value list preserves input
* order.
*/
export const groupToMap =
(keyOf: (row: T) => K, valueFrom: (row: T) => V) =>
(rows: readonly T[]): Map =>
new Map(
[...Map.groupBy(rows, keyOf)].map(([key, group]) => [
key,
group.map(valueFrom),
]),
);
/**
* Key items by their own id, so a caller can look one up. Hand-rolled because
* `@std/collections.associateBy` keys by a string and gives back an object,
* while every caller here has number ids and wants a Map.
*/
export const byId = (
items: readonly T[],
): Map => new Map(items.map((item) => [item.id, item]));
/** Keep the items a test accepts, then take one thing from each. */
export const keepAndTake =
(
keep: (item: T) => boolean,
take: (item: T) => R,
): ((items: readonly T[]) => R[]) =>
(items) =>
items.filter(keep).map(take);
/** Whether two records hold the same value in every named field. */
export const sameOn =
(...fields: readonly (keyof T)[]): ((left: T, right: T) => boolean) =>
(left, right) =>
fields.every((field) => left[field] === right[field]);
/** A Map holding an empty list for each key, ready to be filled. */
export const emptyListsFor = (
keys: readonly Key[],
): Map => new Map(keys.map((key) => [key, []]));
/**
* Whether two sequences hold the same values, in the same order. It stops at
* the first difference, so how long it takes says how much matched: never
* compare secrets with it.
*/
export const sameOrder = (
left: ArrayLike,
right: ArrayLike,
): boolean => {
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index++) {
if (left[index] !== right[index]) return false;
}
return true;
};
/** Read a required map entry, failing where a broken completeness invariant is
* first observed instead of passing an undefined value onward. */
export const requiredMapValue = (
values: ReadonlyMap,
key: Key,
errorMessage: string,
): Value => {
const value = values.get(key);
if (value === undefined) throw new Error(errorMessage);
return value;
};
/**
* Remove null and undefined values from array
*/
export const isNullish = (value: unknown): value is null | undefined =>
value === null || value === undefined;
export const isNotNullish = (value: T | null | undefined): value is T =>
!isNullish(value);
/** Ask whether a value is one of a fixed list: `isOneOf(["a", "b"])("a")` is
* true. Curried, so a list of words becomes one named check. */
export const isOneOf =
(values: readonly string[]): ((value: string | undefined) => boolean) =>
(value) =>
value !== undefined && values.includes(value);
export const compact = (array: (T | null | undefined)[]): T[] =>
array.filter(isNotNullish);
/**
* The keys of `all` that no listed item carries. The second argument list is
* curried so a caller reads "these keys, not covered by that list".
*/
export const notCoveredBy =
(keyOf: (item: T) => K, knowns: readonly T[]) =>
(all: readonly K[]): K[] => {
const carried = new Set(knowns.map(keyOf));
return all.filter((key) => !carried.has(key));
};
/**
* Alternative combinator: try a sequence of producers in order and return the
* first that yields a defined value, or undefined if every one declines.
*
* Producers run lazily and may be async (each is awaited before the next is
* tried), so later, more expensive lookups are skipped once one claims the
* input. Use it to fold an ordered set of fallible parsers/handlers into a
* single "first match wins" lookup instead of an `if … else if …` ladder. A
* `null` result counts as a match (the producer claimed the input but found it
* invalid), distinct from `undefined` ("not mine — try the next").
*/
export const firstMatch = async (
producers: ReadonlyArray<() => T | undefined | Promise>,
): Promise => {
for (const produce of producers) {
const value = await produce();
if (value !== undefined) return value;
}
return;
};
/**
* Lazy evaluation - compute once on first call, cache forever.
* Use instead of `let x = null; const getX = () => x ??= compute();`
*/
export const once = (fn: () => T): (() => T) => {
let computed = false;
let value: T;
return (): T => {
if (!computed) {
value = fn();
computed = true;
}
return value;
};
};
/**
* Resettable lazy reference - like once() but can be reset for testing.
* Returns [get, set] tuple where set(null) resets to uncomputed state.
*/
export const lazyRef = (
fn: () => T,
): [get: () => T, set: (value: T | null) => void] => {
let computed = false;
let value: T;
const get = (): T => {
if (!computed) {
value = fn();
computed = true;
}
return value;
};
const set = (newValue: T | null): void => {
if (newValue === null) {
computed = false;
} else {
value = newValue;
computed = true;
}
};
return [get, set];
};
/**
* Resource management pattern (like Haskell's bracket or try-with-resources).
* Ensures cleanup happens even if the operation throws.
*
* @example
* const withConnection = bracket(
* () => openConnection(),
* (conn) => conn.close()
* );
* const result = await withConnection(async (conn) => conn.query('SELECT 1'));
*/
export const bracket =
(acquire: () => R | Promise, release: (r: R) => void | Promise) =>
async (use: (r: R) => T | Promise): Promise => {
const resource = await acquire();
try {
return await use(resource);
} finally {
await release(resource);
}
};
/**
* Narrow an unknown value to string, defaulting to "" if not a string.
* Replaces `typeof x === "string" ? x : ""` at type boundaries.
*/
export const asString = (value: unknown): string =>
typeof value === "string" ? value : "";
/**
* Join an array of strings into a single string (curried reduce shorthand).
* Replaces the common pattern: reduce((acc: string, s: string) => acc + s, "")
*/
export const joinStrings = reduce((acc: string, s: string) => acc + s, "");
/**
* Curried sum-by-selector. Adds up the numbers produced by `selector` for each
* item. Curried adapter over `@std/collections.sumOf`.
* Replaces the common pattern: reduce((acc, x) => acc + selector(x), 0)
*/
export const sumOf =
(selector: (item: T) => number) =>
(array: Iterable): number =>
stdSumOf(array, selector);
/**
* Sum an array of numbers (identity selector shorthand for sumOf).
* Replaces the common pattern: reduce((acc, n) => acc + n, 0)
*/
export const sum = sumOf((n: number) => n);
/**
* Curried group-and-sum: accumulate `valueOf(item)` into a `Map` keyed by
* `keyOf(item)`. Replaces the common pattern:
* const m = new Map(); for (const x of xs) m.set(k(x), (m.get(k(x)) ?? 0) + v(x))
*/
export const sumByKey =
(keyOf: (item: T) => K, amountOf: (item: T) => number) =>
(items: Iterable): Map => {
const totals = new Map();
for (const item of items) {
const key = keyOf(item);
totals.set(key, (totals.get(key) ?? 0) + amountOf(item));
}
return totals;
};
/** Return a value unchanged. */
export const identity = (value: T): T => value;
/** Index items by one field and chosen value. Keys keep first-occurrence order,
* while later matching items replace the stored value. */
export const mapBy =
(key: Key, valueFrom: (item: T) => V) =>
(items: Iterable): Map => {
const result = new Map();
for (const item of items) result.set(item[key], valueFrom(item));
return result;
};
/** Index items by `id` and a chosen value. */
export const mapById = (
valueFrom: (item: T) => V,
): ((items: Iterable) => Map) => mapBy("id", valueFrom);
/** Index one field from each item by its `id`. */
export const fieldById =
(field: Field) =>
>(
items: Iterable,
): Map =>
mapById((item: T) => item[field])(items);
/**
* A copy of `base` with `extra` entries merged on top (an `extra` key wins over
* the same key in `base`). Curried so a constant overlay (a fixed content type,
* a default set) can extend whatever record it is given.
*/
export const extendedBy =
(extra: Record) =>
(base: Record): Record => ({ ...base, ...extra });
/**
* Split an array into chunks of a given size.
* Curried adapter over `@std/collections.chunk` (which throws for size < 1).
*/
export const chunk =
(size: number) =>
(array: T[]): T[][] =>
stdChunk(array, size);
/**
* Whole numbers counting up from `start`, stopping before `end` ([] when `end`
* is not past `start`). Hand-rolled because `@std/collections` has no numeric
* range helper. Iterating this bounded array replaces hand-counted loops,
* which a single slip in their step can make endless.
*/
export const range = (start: number, end: number): number[] =>
Array.from({ length: Math.max(0, end - start) }, (_, i) => start + i);
/**
* Map over a promise-returning function in parallel (Promise.all)
*/
export const mapParallel =
(fn: (item: T) => Promise) =>
(array: readonly T[]): Promise =>
Promise.all(array.map(fn));
/** Collection cache returned by collectionCache() */
export type CollectionCache = {
getAll: () => Promise;
invalidate: () => void;
size: () => number;
};
/**
* Create an in-memory collection cache with TTL.
* Loads all items via fetchAll on first access or after invalidation/expiry,
* then serves from memory until the TTL expires or invalidate() is called.
* Takes the clock as an argument: pass nowMs, or a fake clock in a test.
*
* Uses a generation counter to prevent a race condition where a concurrent
* fetchAll() that started before an invalidation could overwrite the cache
* with stale data.
*/
export const collectionCache = (
fetchAll: () => Promise,
ttlMs: number,
now: () => number,
): CollectionCache => {
const [getState, setState] = lazyRef<{ items: T[] | null; time: number }>(
() => ({ items: null, time: 0 }),
);
let generation = 0;
return {
getAll: async (): Promise => {
const state = getState();
if (state.items !== null && now() - state.time <= ttlMs) {
return state.items;
}
const gen = generation;
const items = await fetchAll();
if (gen === generation) {
setState({ items, time: now() });
}
return items;
},
invalidate: (): void => {
generation++;
setState(null);
},
size: (): number => getState().items?.length ?? 0,
};
};
/** TTL cache returned by ttlCache() */
export type TtlCache = {
get: (key: K) => V | undefined;
set: (key: K, value: V) => void;
clear: () => void;
size: () => number;
};
/**
* Create a TTL (Time-To-Live) cache.
* Entries expire after ttlMs milliseconds.
* Takes the clock as an argument: pass nowMs, or a fake clock in a test.
* Give maxEntries a value to bound the cache: storing a new key at the cap
* drops the oldest-stored entry first. Bound any cache whose keys can be
* chosen from outside (e.g. unknown session cookies), so a flood of unique
* keys cannot grow it without limit. Leave it unset for caches whose keyspace
* our own data already bounds — the keyed entity caches rely on holding every
* row of their table.
*/
export const ttlCache = (
ttlMs: number,
now: () => number,
maxEntries?: number,
): TtlCache => {
const cache = new Map();
return {
clear: (): void => {
cache.clear();
},
get: (key: K): V | undefined => {
const entry = cache.get(key);
if (!entry) return;
if (now() - entry.cachedAt > ttlMs) {
cache.delete(key);
return;
}
return entry.value;
},
set: (key: K, value: V): void => {
// Deleting first re-inserts a refreshed key at the newest position, so
// the Map's first key is always the oldest-stored entry.
cache.delete(key);
if (maxEntries !== undefined && cache.size >= maxEntries) {
const oldest = cache.keys().next();
if (!oldest.done) cache.delete(oldest.value);
}
cache.set(key, { cachedAt: now(), value });
},
size: (): number => cache.size,
};
};