UNPKG

@conjecture-dev/g-std

Version:

A collection of TypeScript utility functions for common programming tasks

374 lines 11.9 kB
"use strict"; import { Failure, Success } from "./outcome"; export * from "./outcome"; // @g-check: all functions should have comments /** * Maps over an object's entries, transforming each value while preserving keys * @param obj The source object to transform * @param fn Mapping function that receives key, value, and index * @returns New object with transformed values and same keys */ export function objectMap(obj, fn) { return Object.fromEntries(Object.entries(obj).map(([key, value], index) => [ key, fn(key, value, index), ])); } /** * Filters and maps an array in one pass, using an Option-like pattern * @param arr Source array to transform * @param fn Function that returns ['some', value] to keep an item or ['none'] to filter it out * @returns Array of transformed values that weren't filtered out */ export const arrayFilterMap = (arr, fn) => { return arr.map(fn).filter((x) => x[0] === 'some').map((x) => x[1]); }; /** * Return true if the array is non-empty, and make typescript inference happy about you using myArray[0] * @param arr the array * @returns a type guard/boolean */ export const isNonEmpty = (arr) => { return arr.length > 0; }; /** * Recursively filters an object or array's keys at any depth * @param obj Source object or array to filter * @param fn Predicate function that returns true to keep a key, false to remove it * @returns New object with filtered keys */ export const deepObjectFilterK = (obj, fn) => { if (Array.isArray(obj)) { return obj.map(x => deepObjectFilterK(x, fn)); } if (typeof obj !== 'object' || obj === null) { return obj; } return Object.fromEntries(arrayFilterMap(Object.entries(obj), ([key, value]) => { const fnd = fn(key); if (!fnd) return ['none']; return ['some', [key, deepObjectFilterK(value, fn)]]; })); }; /** * Helper function for exhaustive type checking * @param x Value that should be impossible to pass (type never) * @throws Error if called, indicating a type checking failure */ export const assertNever = (x) => { throw new Error(`Assert never ${JSON.stringify(x)}`); }; /** * Checks if all items in an iterable are truthy * @param iterable Collection to check * @returns true if all items are truthy, false otherwise */ export const all = (iterable) => { for (const item of iterable) { if (!item) { return false; } } return true; }; /** * Creates an array of tuples containing index and value pairs * @param arr Source array * @returns Array of [index, value] tuples */ export const enumerate = (arr) => { return arr.map((x, ix) => [ix, x]); }; /** * Creates a canonical string representation of a value * Sorts object keys and handles nested structures * @param value Any value to canonicalize * @returns Consistent string representation */ export const canonicalize = (value) => { const aux = (value) => { if (typeof value === 'object' && value !== null) { if (Array.isArray(value)) { return value.map(aux); } else { const sortedObj = {}; Object.keys(value).sort().forEach(key => { sortedObj[key] = aux(value[key]); }); return sortedObj; } } return value; }; return JSON.stringify(aux(value), null, 2); }; /** * Finds duplicate values in an array using canonical string representation * @param arr Array to check for duplicates * @returns Array of duplicate values */ export const findDuplicates = (arr) => { return arr.filter((x, i) => arr.filter((y, j) => i < j && canonicalize(x) === canonicalize(y)).length > 0); }; /** * Template literal tag for type-safe string interpolation * @param strs Template string array * @param params Values to interpolate (must be string, number, or boolean) * @returns Interpolated string * @throws Error if any param is not a string, number, or boolean */ export const f = (strs, ...params) => { // When calling f`Hello ${name}!`, strs is ["Hello ", "!"] and params is [name] // Typescript ensures that strs is always one element longer than params // We iterate over params, and for each param, we check if it is a string, number, or boolean for (let param of params) { if (typeof param === 'string' || typeof param === 'boolean' || typeof param === 'number') continue; else throw new Error('Was not a string'); } let str = ""; for (let i = 0; i < params.length; i++) { str += strs[i] + params[i]; } str += strs[params.length]; return str; }; /** * Creates an immutable (frozen) copy of an object or array * @param x Object or array to freeze * @returns Frozen copy of the input */ export const ice = (x) => { return Object.freeze(x); }; /** * Type-safe version of Object.fromEntries * @param entries Array of key-value pairs * @returns Object constructed from entries with preserved types */ export const objectFromEntries = (entries) => { return Object.fromEntries(entries); }; /** * Type-safe version of Object.entries * @param obj Source object * @returns Array of key-value pairs with preserved types */ export const objectEntries = (obj) => { return Object.entries(obj); }; /** * Check if any item in the iterable is truthy * * @param iterable Collection to check */ export const any = (iterable) => { for (const item of iterable) { if (item) { return true; } } return false; }; /** * Transposes two arrays into a single array of tuples (like python's zip) * @param a First array * @param b Second array * @returns Array of tuples * @throws Error if arrays are of different lengths */ export const zip = (a, b) => { if (a.length !== b.length) { throw Error(`Called zip on arrays of different lenghts: ${a.length} and ${b.length}`); } return a.map((_, index) => [a[index], b[index]]); }; /** * Transposes two arrays into a single array of tuples (like python's zip) in a safe way * @param a First array * @param b Second array * @returns Array of tuples if a and b have the same size. Otherwise, return the size of a and b */ export const zipOutcome = (a, b) => { if (a.length !== b.length) { return Failure({ a: a.length, b: b.length }); } return Success(a.map((_, index) => [a[index], b[index]])); }; /** * Sorts an array (not in place) * @param arr Array to sort * @param compareFn Comparison function (returns -1 if a < b, 0 if a == b, 1 if a > b) * @returns Sorted array */ export const sorted = (arr, compareFn) => { return [...arr].sort(compareFn); }; /** * Same as Promise.withResolvers, but in 2024 * @returns Object containing a promise and its resolve/reject functions */ export const promiseWithResolvers = () => { let resolve; let reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); if (resolve === undefined || reject === undefined) { throw Error("This really shouldn't happen"); } return { promise, resolve, reject }; }; /** * Prevents a function from being called more than once within a specified time interval * * Rejected calls return Failure("throttled") * * @param options How many milliseconds to wait before allowing another call * @param inputFn Function to throttle * @returns Throttled function */ export const throuncedAsync = (inputFn, options) => { let lastCallTimestamp = 0; let lastPendingCall = null; let waiting = []; const doFlush = async (now, ...is) => { lastCallTimestamp = now; lastPendingCall = null; const result = await inputFn(...is); for (const resolve of waiting) { resolve(); } return Success(result); }; const augmentedFn = async (...is) => { const { promise, resolve, reject } = promiseWithResolvers(); const now = Date.now(); if (now - lastCallTimestamp < options.throunceMs) { lastPendingCall = is; setTimeout(async () => { if (lastPendingCall === is) { try { resolve(await doFlush(now, ...is)); } catch (error) { reject(error); } } else { resolve(Failure("throttled")); } }, options.throunceMs); return promise; } lastCallTimestamp = now; lastPendingCall = null; return doFlush(now, ...is); }; const flush = () => { if (lastPendingCall === null) { return; } const { promise, resolve, reject: _ } = promiseWithResolvers(); waiting = [ ...waiting, resolve, ]; return promise; }; return { fn: augmentedFn, flush }; }; /** * Class that remembers a last value and can tell if a new value is different */ export class ChangeDetector { lastValueCanonicalized = null; hasChanged(newValue) { const canonicalized = canonicalize(newValue); if (this.lastValueCanonicalized === canonicalized) { return false; } this.lastValueCanonicalized = canonicalized; return true; } } /** * Flattens an array of arrays * @param arr Array of arrays * @returns Flattened array */ export const flatten = (arr) => { return [...arr].flat(); }; /** * Same as Array.prototype.findIndex, but returns null if the item is not found * @param arr Array to search * @param fn Function to test each element * @returns Index of the first element that satisfies the function, or null if not found */ export const safeFindIndex = (arr, fn) => { const index = arr.findIndex(fn); return index === -1 ? null : index; }; /** * Checks if all items in an array are non-null * @param arr Array to check * @returns true if all items are non-null, false otherwise */ export const allNonNull = (arr) => { return arr.every((item) => item !== null); }; /** * Returns all consecutive subarrays of length num * @param arr Array to check * @param num Length of the subarrays * @returns All consecutive subarrays of length num */ export const consecutive = (arr, num, options) => { const padding = options?.padding ?? false; let result = []; if (!padding) { for (let i = 0; i < arr.length; i++) { if (i + num <= arr.length) { result.push(arr.slice(i, i + num)); } } } else { if (options?.default === undefined) { throw Error("Default is required when padding is true"); } const numPadding = num - Math.min(1, arr.length); const before = Array(numPadding).fill(options.default); const after = Array(numPadding).fill(options.default); return consecutive([...before, ...arr, ...after], num, { ...options, padding: false }); } return result; }; /** * Parses a string as an integer, returning null if the string is not a valid integer * @param x String to parse * @returns Parsed integer, or null if the string is not a valid integer */ export const safeParseInt = (x) => { const parsed = parseInt(x); if (x.replace(/^\+/, "") !== parsed.toString()) { return null; } return parsed; }; /** * Clamps a number between a minimum and maximum value * @param x The number to clamp * @param min The minimum value * @param max The maximum value * @returns The clamped number */ export const clamp = (x, min, max) => { return Math.min(Math.max(x, min), max); }; //# sourceMappingURL=index.js.map