diff --git a/.changeset/cozy-wings-tell.md b/.changeset/cozy-wings-tell.md new file mode 100644 index 0000000..ac2a81d --- /dev/null +++ b/.changeset/cozy-wings-tell.md @@ -0,0 +1,5 @@ +--- +"@certes/composition": minor +--- + +Rewrite curry internals to increase execution performance diff --git a/.changeset/modern-days-drive.md b/.changeset/modern-days-drive.md new file mode 100644 index 0000000..f67d03e --- /dev/null +++ b/.changeset/modern-days-drive.md @@ -0,0 +1,5 @@ +--- +"@certes/common": patch +--- + +Change types for lookup to use function overloads for better type inference diff --git a/.changeset/tough-planets-fly.md b/.changeset/tough-planets-fly.md new file mode 100644 index 0000000..11dc672 --- /dev/null +++ b/.changeset/tough-planets-fly.md @@ -0,0 +1,5 @@ +--- +"@certes/composition": patch +--- + +Rewrite compose and pipe types to increase accuracy of type inference diff --git a/packages/combinator/vitest.config.js b/packages/combinator/vitest.config.js index 9b624b7..d601790 100644 --- a/packages/combinator/vitest.config.js +++ b/packages/combinator/vitest.config.js @@ -16,5 +16,6 @@ export default defineConfig({ clearMocks: true, restoreMocks: true, passWithNoTests: true, + silent: 'passed-only', }, }); diff --git a/packages/common/README.md b/packages/common/README.md index c88bafc..d369168 100644 --- a/packages/common/README.md +++ b/packages/common/README.md @@ -26,14 +26,16 @@ Creates a curried lookup function with optional default handling for missing key **Type Signature** ```typescript +// Without default function function lookup( - obj: A, - def: B -): (prop: string | number | symbol) => ReturnType; + obj: T +): (prop: PropertyKey) => T[keyof T] | undefined; +// With default function function lookup( - obj: A -): (prop: string | number | symbol) => A[keyof A] | undefined; + obj: T, + def: (value: T[keyof T] | undefined) => R +): (prop: PropertyKey) => T[keyof T] | R; ``` **Parameters** @@ -58,7 +60,7 @@ type Statuses = typeof statusCodes[keyof typeof statusCodes]; const getStatus = lookup(statusCodes, (x: Statuses | undefined) => x ?? 'Unknown'); getStatus(200); // 'OK' -getStatus(999); // 'Unknown' +getStatus(999); // Statuses | 'Unknown' ``` --- diff --git a/packages/common/src/lookup/index.ts b/packages/common/src/lookup/index.ts index 5aa3a1e..c804c15 100644 --- a/packages/common/src/lookup/index.ts +++ b/packages/common/src/lookup/index.ts @@ -1,51 +1,93 @@ import { identity } from '@certes/combinator/i'; +/** + * The value type of a record. + */ +type ValueOf = T[keyof T]; + +/** + * Checks if a PropertyKey is a literal type (not the wide `string`, `number`, or `symbol`). + */ +type IsLiteralKey = string extends K + ? false + : number extends K + ? false + : symbol extends K + ? false + : true; + /** * Creates a lookup function that retrieves values from a record with optional default handling. * * This is a curried function that first takes a lookup table and optional default handler, * then returns a function that performs the actual property lookup. * - * @template A - The record type used as the lookup table. Must extend Record. - * @template B - The type of the default handler function. Must be a function that accepts unknown and returns unknown. + * @template T - The record type used as the lookup table. * * @param obj - The lookup table record. - * @param def - Optional function to handle missing or undefined values. Receives the looked-up value (possibly undefined) and returns a default. If not provided, uses identity function. - * - * @returns A lookup function that accepts a property key and returns the associated value or default. + * @param def - Optional function to handle values (including undefined for missing keys). + * Receives the looked-up value and returns a transformed result. + * If not provided, uses identity function. * - * @remarks - * Pure function. The returned lookup function accepts `string | number | symbol` keys, allowing - * lookups of properties that may not exist in the type definition. When a key doesn't exist, - * the default function receives `undefined`. - * - * Type signature: `(Record, (V | undefined -> W)?) -> (K -> W | V)` + * @returns A lookup function with intelligent return type narrowing: + * - Known literal key (`'FOO'`): exact type `T[K]` + * - Unknown literal key (`'MISSING'`): `R` (with default) or `undefined` (without) + * - Dynamic key (`string`): `ValueOf | R` or `ValueOf | undefined` * * @example + * ```ts * const colorTable = { * FOO: [0, 0, 255, 155], * BAR: [255, 0, 255, 155], - * FIZZ: [230, 0, 0, 155], - * BUZZ: [0, 128, 0, 155], * } as const; * - * const colorLookup = lookup(colorTable, x => x ?? [128, 128, 128, 155]); + * const colorLookup = lookup(colorTable, () => [128, 128, 128, 155] as const); + * + * // Known literal key - narrows to exact type + * colorLookup('FOO'); // readonly [0, 0, 255, 155] + * + * // Unknown literal key - only the default + * colorLookup('MISSING'); // readonly [128, 128, 128, 155] + * + * // Dynamic key - union of all possibilities + * const key: string = getKey(); + * colorLookup(key); // ValueOf | readonly [128, 128, 128, 155] * - * colorLookup('FOO'); // [0, 0, 255, 155] - * colorLookup('MISSING'); // [128, 128, 128, 155] (default) + * // Without default + * const directLookup = lookup(colorTable); + * directLookup('FOO'); // readonly [0, 0, 255, 155] + * directLookup('MISSING'); // undefined + * directLookup(key); // ValueOf | undefined + * ``` */ -export const lookup = - < - A extends Record, - // biome-ignore lint/suspicious/noExplicitAny: This is intended - B extends (...args: any[]) => any, - >( - obj: A, - def?: B, - ) => - (prop: string | number | symbol): A[C] => { - const fn = def ?? identity; - const value = Object.hasOwn(obj, prop) ? obj[prop as keyof A] : undefined; +export function lookup>( + obj: T, +): ((prop: K) => T[K]) & + (( + prop: IsLiteralKey extends true ? Exclude : never, + ) => undefined) & + ((prop: PropertyKey) => ValueOf | undefined); + +export function lookup, R>( + obj: T, + def: (value: ValueOf | undefined) => R, +): ((prop: K) => T[K]) & + (( + prop: IsLiteralKey extends true ? Exclude : never, + ) => R) & + ((prop: PropertyKey) => ValueOf | R); + +export function lookup, R>( + obj: T, + def?: (value: ValueOf | undefined) => R, +): (prop: PropertyKey) => ValueOf | undefined | R { + const fn = def ?? identity; + + return (prop: PropertyKey): ValueOf | undefined | R => { + const value = Object.hasOwn(obj, prop) + ? (obj[prop as keyof T] as ValueOf) + : undefined; return fn(value); }; +} diff --git a/packages/common/src/lookup/lookup.test-d.ts b/packages/common/src/lookup/lookup.test-d.ts new file mode 100644 index 0000000..a708532 --- /dev/null +++ b/packages/common/src/lookup/lookup.test-d.ts @@ -0,0 +1,196 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { lookup } from '.'; + +describe('Lookup', () => { + it('should narrow to exact type for known literal keys', () => { + const colorTable = { + FOO: [0, 0, 255, 155] as const, + BAR: [255, 0, 255, 155] as const, + FIZZ: [230, 0, 0, 155] as const, + } as const; + + const directLookup = lookup(colorTable); + const withDefault = lookup(colorTable, () => [128, 128, 128, 155] as const); + + // Known literal key - should narrow to exact type + expectTypeOf(directLookup('FOO')).toEqualTypeOf< + readonly [0, 0, 255, 155] + >(); + expectTypeOf(directLookup('BAR')).toEqualTypeOf< + readonly [255, 0, 255, 155] + >(); + + expectTypeOf(withDefault('FOO')).toEqualTypeOf(); + expectTypeOf(withDefault('FIZZ')).toEqualTypeOf< + readonly [230, 0, 0, 155] + >(); + }); + + it('should return undefined for unknown literal keys without default', () => { + const colorTable = { + FOO: [0, 0, 255, 155] as const, + BAR: [255, 0, 255, 155] as const, + } as const; + + const directLookup = lookup(colorTable); + + // Unknown literal key - should be undefined + expectTypeOf(directLookup('MISSING')).toEqualTypeOf(); + expectTypeOf(directLookup('NOPE')).toEqualTypeOf(); + }); + + it('should return default type for unknown literal keys with default', () => { + const colorTable = { + FOO: [0, 0, 255, 155] as const, + BAR: [255, 0, 255, 155] as const, + } as const; + + const defaultColor = [128, 128, 128, 155] as const; + const withDefault = lookup(colorTable, () => defaultColor); + + // Unknown literal key - should be the default type + expectTypeOf(withDefault('MISSING')).toEqualTypeOf< + readonly [128, 128, 128, 155] + >(); + expectTypeOf(withDefault('NOPE')).toEqualTypeOf< + readonly [128, 128, 128, 155] + >(); + }); + + it('should return union type for dynamic keys without default', () => { + const colorTable = { + FOO: [0, 0, 255, 155] as const, + BAR: [255, 0, 255, 155] as const, + } as const; + + const directLookup = lookup(colorTable); + + // Dynamic string key - should be union of all values | undefined + const dynamicKey: string = 'FOO'; + expectTypeOf(directLookup(dynamicKey)).toEqualTypeOf< + readonly [0, 0, 255, 155] | readonly [255, 0, 255, 155] | undefined + >(); + }); + + it('should return union type for dynamic keys with default', () => { + const colorTable = { + FOO: [0, 0, 255, 155] as const, + BAR: [255, 0, 255, 155] as const, + } as const; + + const defaultColor = [128, 128, 128, 155] as const; + const withDefault = lookup(colorTable, () => defaultColor); + + // Dynamic string key - should be union of all values | default type + const dynamicKey: string = 'FOO'; + expectTypeOf(withDefault(dynamicKey)).toEqualTypeOf< + | readonly [0, 0, 255, 155] + | readonly [255, 0, 255, 155] + | readonly [128, 128, 128, 155] + >(); + }); + + it('should handle numeric keys correctly', () => { + const statusCodes = { + 200: 'OK' as const, + 404: 'Not Found' as const, + 500: 'Internal Server Error' as const, + } as const; + + const getStatus = lookup(statusCodes, () => 'Unknown' as const); + + // Known numeric literal + expectTypeOf(getStatus(200)).toEqualTypeOf<'OK'>(); + expectTypeOf(getStatus(404)).toEqualTypeOf<'Not Found'>(); + + // Unknown numeric literal + expectTypeOf(getStatus(999)).toEqualTypeOf<'Unknown'>(); + + // Dynamic number + const code: number = 200; + expectTypeOf(getStatus(code)).toEqualTypeOf< + 'OK' | 'Not Found' | 'Internal Server Error' | 'Unknown' + >(); + }); + + it('should handle symbol keys correctly', () => { + const sym1 = Symbol('test'); + const sym2 = Symbol('other'); + + const symbolTable = { + [sym1]: 'found' as const, + } as const; + + const symLookup = lookup(symbolTable, () => 'not-found' as const); + + // Known symbol literal + expectTypeOf(symLookup(sym1)).toEqualTypeOf<'found'>(); + + // Unknown symbol literal + expectTypeOf(symLookup(sym2 as typeof sym2)).toEqualTypeOf<'not-found'>(); + }); + + it('should preserve const assertion types', () => { + const config = { + apiUrl: 'https://api.example.com' as const, + timeout: 5000 as const, + enabled: true as const, + } as const; + + const getConfig = lookup(config); + + // Each key should preserve its exact literal type + expectTypeOf( + getConfig('apiUrl'), + ).toEqualTypeOf<'https://api.example.com'>(); + expectTypeOf(getConfig('timeout')).toEqualTypeOf<5000>(); + expectTypeOf(getConfig('enabled')).toEqualTypeOf(); + }); + + it('should handle default function with type transformation', () => { + const numberTable = { + one: 1, + two: 2, + three: 3, + } as const; + + // Default function transforms undefined to string + const getLookup = lookup(numberTable, (x: 1 | 2 | 3 | undefined) => + x === undefined ? 'unknown' : x.toString(), + ); + + // Known key should still return the original number type + expectTypeOf(getLookup('one')).toEqualTypeOf<1>(); + + // Unknown key should return the default type (string) + expectTypeOf(getLookup('four')).toEqualTypeOf(); + + // Dynamic key should be union of both + const key: string = 'one'; + expectTypeOf(getLookup(key)).toEqualTypeOf<1 | 2 | 3 | string>(); + }); + + it('should handle mutable vs immutable tables', () => { + // Mutable table (no const assertion) + const mutableTable = { + foo: [1, 2, 3], + bar: [4, 5, 6], + }; + + const mutableLookup = lookup(mutableTable); + + // Should return mutable array type + expectTypeOf(mutableLookup('foo')).toEqualTypeOf(); + + // Immutable table (with const assertion) + const immutableTable = { + foo: [1, 2, 3] as const, + bar: [4, 5, 6] as const, + } as const; + + const immutableLookup = lookup(immutableTable); + + // Should return exact readonly tuple type + expectTypeOf(immutableLookup('foo')).toEqualTypeOf(); + }); +}); diff --git a/packages/common/vitest.config.js b/packages/common/vitest.config.js index 9b624b7..d601790 100644 --- a/packages/common/vitest.config.js +++ b/packages/common/vitest.config.js @@ -16,5 +16,6 @@ export default defineConfig({ clearMocks: true, restoreMocks: true, passWithNoTests: true, + silent: 'passed-only', }, }); diff --git a/packages/composition/README.md b/packages/composition/README.md index f74676d..7f09fba 100644 --- a/packages/composition/README.md +++ b/packages/composition/README.md @@ -86,6 +86,7 @@ await pipeline(42); ### compose(...fns) Composes functions from right to left. + ```typescript const fn = compose(f, g, h); fn(x) === f(g(h(x))); @@ -94,11 +95,12 @@ fn(x) === f(g(h(x))); **Constraints:** - Last function can accept n parameters - All other functions must be unary -- Maximum depth: 1000 functions +- Type inference supported for up to 10 functions (nest multiple calls for more) ### pipe(...fns) Composes functions from left to right. + ```typescript const fn = pipe(f, g, h); fn(x) === h(g(f(x))); @@ -107,7 +109,7 @@ fn(x) === h(g(f(x))); **Constraints:** - First function can accept n parameters - All other functions must be unary -- Maximum depth: 1000 functions +- Type inference supported for up to 10 functions (nest multiple calls for more) ### composeAsync(...fns) @@ -127,7 +129,7 @@ await fn(x) === await f(await g(await h(x))); **Constraints:** - Last function can accept n parameters - All other functions must be unary -- Maximum depth: 1000 functions +- Type inference supported for up to 10 functions (nest multiple calls for more) ### pipeAsync(...fns) @@ -147,11 +149,12 @@ await fn(x) === await h(await g(await f(x))); **Constraints:** - First function can accept n parameters - All other functions must be unary -- Maximum depth: 1000 functions +- Type inference supported for up to 10 functions (nest multiple calls for more) ### curry(fn) Returns a curried version of the function. + ```typescript const add = (a: number, b: number, c: number) => a + b + c; const curriedAdd = curry(add); @@ -160,7 +163,11 @@ const add5 = curriedAdd(5); add5(3, 2); // 10 ``` -**Note:** Uses `Function.prototype.length` for arity detection. Rest parameters and default parameters may not curry as expected. +**Constraints:** +- **Supports functions with 0-6 parameters only** +- **Functions with 7+ parameters will throw `RangeError`** +- Uses `Function.prototype.length` for arity detection +- Rest parameters and default parameters may not curry as expected ## License diff --git a/packages/composition/src/compose-async/compose-async.test.ts b/packages/composition/src/compose-async/compose-async.test.ts index 5880019..91692b5 100644 --- a/packages/composition/src/compose-async/compose-async.test.ts +++ b/packages/composition/src/compose-async/compose-async.test.ts @@ -118,17 +118,6 @@ describe('ComposeAsync', () => { await expect(composed(5)).rejects.toThrow('Sync error'); }); - - it('should throw on excessive composition depth', () => { - const functions = Array(1001).fill(asyncAdd3); - - // @ts-expect-error For testing - expect(() => composeAsync(...functions)).toThrow(RangeError); - // @ts-expect-error For testing - expect(() => composeAsync(...functions)).toThrow( - 'Async composition depth exceeds 1000', - ); - }); }); describe('Async Execution Order', () => { @@ -188,12 +177,8 @@ describe('ComposeAsync', () => { const g = asyncMultiply2; const h = asyncSubtract1; - // Create intermediate compositions with explicit types - const gh: (x: number) => Promise = composeAsync(g, h); - const fg: (x: number) => Promise = composeAsync(f, g); - - const left = composeAsync(f, gh); - const right = composeAsync(fg, h); + const left = composeAsync(f, composeAsync(g, h)); + const right = composeAsync(composeAsync(f, g), h); const direct = composeAsync(f, g, h); const testValue = 10; @@ -209,11 +194,8 @@ describe('ComposeAsync', () => { const g = multiply2; // sync const h = asyncSubtract1; - const gh: (x: number) => Promise = composeAsync(g, h); - const fg: (x: number) => Promise = composeAsync(f, g); - - const left = composeAsync(f, gh); - const right = composeAsync(fg, h); + const left = composeAsync(f, composeAsync(g, h)); + const right = composeAsync(composeAsync(f, g), h); const testValue = 10; diff --git a/packages/composition/src/compose-async/index.ts b/packages/composition/src/compose-async/index.ts index 2a1838f..4121c0a 100644 --- a/packages/composition/src/compose-async/index.ts +++ b/packages/composition/src/compose-async/index.ts @@ -1,95 +1,34 @@ import type { UnaryAsyncFunction } from '../types'; /** - * Defines the valid shapes for async function arrays that can be composed. - * - * This union type represents two possible async compositions: - * 1. An array with zero or more unary async functions followed by one n-ary async function - * 2. A single n-ary async function - * - * Functions can return either Promise or T (sync functions are auto-wrapped). - */ -type AsyncCompositionArray = - // biome-ignore lint/suspicious/noExplicitAny: This is intended - | readonly [...UnaryAsyncFunction[], (...args: any[]) => Promise | any] - // biome-ignore lint/suspicious/noExplicitAny: This is intended - | readonly [(...args: any[]) => Promise | any]; - -/** - * Extracts the parameter types of the rightmost (last) function in an async composition array. + * Async function type that can return either a Promise or a synchronous value. */ -type ComposeAsyncParams = - Fns extends readonly [...unknown[], infer Last] - ? // biome-ignore lint/suspicious/noExplicitAny: This is intended - Last extends (...args: infer P) => any - ? P - : never - : never; +type AsyncFn = ( + ...args: Args +) => Promise | R; /** - * Extracts the awaited return type of the leftmost (first) function in an async composition array. - */ -type ComposeAsyncReturn = - Fns extends readonly [infer First, ...unknown[]] - ? // biome-ignore lint/suspicious/noExplicitAny: This is intended - First extends (...args: any[]) => infer R - ? Awaited - : never - : never; - -/** - * Validates and transforms an async function array to ensure valid composition structure. - */ -type ComposableAsync = Fn extends readonly [ - // biome-ignore lint/suspicious/noExplicitAny: This is intended - (...args: any[]) => any, -] - ? Fn - : Fn extends readonly [ - infer First extends UnaryAsyncFunction, - ...infer Rest extends AsyncCompositionArray, - ] - ? readonly [First, ...ComposableAsync] - : never; - -const MAX_ASYNC_COMPOSITION_DEPTH = 1000; - -/** - * Composes async functions from right to left. - * - * @template Fns - Tuple type of composable async functions, where all but the last must be unary - * - * @param fns - The functions to compose in right-to-left order + * Right-to-left variadic async function composition. * - * @returns A new async function accepting the parameters of the rightmost function in `fns`, - * returning a Promise of the return type of the leftmost function in `fns` + * Combines two or more functions (sync or async) to create a new async function, + * passing the awaited result from one function to the next until all have been called. + * The rightmost function is applied first to the input arguments. * * @remarks - * The implementation follows right-to-left composition semantics: - * 1. The rightmost function is applied first to the input arguments - * 2. Each subsequent function (moving left) receives the awaited result of the previous function - * 3. The leftmost function's result becomes the final output - * - * Mathematical notation: (f ∘ g ∘ h)(x) = await f(await g(await h(x))) + * Mathematical notation: `(f ∘ g ∘ h)(x) = await f(await g(await h(x)))` * * Type constraints: - * - The last function can accept n parameters + * - The rightmost (last) function can accept n parameters * - All other functions must be unary (single parameter) - * - Functions can return Promise or T (sync functions auto-wrapped) - * - Final return type is always Promise - * - * Performance: O(n) time complexity, O(1) space complexity where n is the number of functions. - * Uses iterative implementation with await to handle async operations. - * - * @example - * const fetchUser = async (id: number): Promise => { ... }; - * const getEmail = (user: User): string => user.email; - * const sendEmail = async (email: string): Promise => { ... }; + * - Functions can return `Promise` or `T` (sync functions auto-wrapped) + * - Final return type is always `Promise` + * - Return type of function `i` must be assignable to parameter of function `i-1` * - * const notifyUser = composeAsync(sendEmail, getEmail, fetchUser); - * await notifyUser(123); + * The overload-based signature provides reliable type inference up to 10 functions. + * For compositions exceeding 10 functions, nest multiple composeAsync calls. * * @example + * ```ts * // Mix of sync and async functions * const transform = composeAsync( * async (x: string) => x.toUpperCase(), // async @@ -97,36 +36,174 @@ const MAX_ASYNC_COMPOSITION_DEPTH = 1000; * async (x: number) => x + 3 // async * ); * await transform(4); // "7" + * + * // With n-ary rightmost function + * const fetchAndProcess = composeAsync( + * formatResult, + * parseJSON, + * async (url: string, options: RequestInit) => fetch(url, options) + * ); + * await fetchAndProcess('https://api.example.com', { method: 'GET' }); + * ``` */ -export const composeAsync = ( - ...fns: ComposableAsync -): ((...args: ComposeAsyncParams) => Promise>) => { +export function composeAsync( + f: AsyncFn, +): (...args: Args) => Promise>; + +export function composeAsync( + f: (a: Awaited) => Promise | B, + g: AsyncFn, +): (...args: Args) => Promise>; + +export function composeAsync( + f: (b: Awaited) => Promise | C, + g: (a: Awaited) => Promise | B, + h: AsyncFn, +): (...args: Args) => Promise>; + +export function composeAsync( + f: (c: Awaited) => Promise | D, + g: (b: Awaited) => Promise | C, + h: (a: Awaited) => Promise | B, + i: AsyncFn, +): (...args: Args) => Promise>; + +export function composeAsync( + f: (d: Awaited) => Promise | E, + g: (c: Awaited) => Promise | D, + h: (b: Awaited) => Promise | C, + i: (a: Awaited) => Promise | B, + j: AsyncFn, +): (...args: Args) => Promise>; + +export function composeAsync( + f: (e: Awaited) => Promise | F, + g: (d: Awaited) => Promise | E, + h: (c: Awaited) => Promise | D, + i: (b: Awaited) => Promise | C, + j: (a: Awaited) => Promise | B, + k: AsyncFn, +): (...args: Args) => Promise>; + +export function composeAsync< + Args extends readonly unknown[], + A, + B, + C, + D, + E, + F, + G, +>( + f: (f_: Awaited) => Promise | G, + g: (e: Awaited) => Promise | F, + h: (d: Awaited) => Promise | E, + i: (c: Awaited) => Promise | D, + j: (b: Awaited) => Promise | C, + k: (a: Awaited) => Promise | B, + l: AsyncFn, +): (...args: Args) => Promise>; + +export function composeAsync< + Args extends readonly unknown[], + A, + B, + C, + D, + E, + F, + G, + H, +>( + f: (g_: Awaited) => Promise | H, + g: (f_: Awaited) => Promise | G, + h: (e: Awaited) => Promise | F, + i: (d: Awaited) => Promise | E, + j: (c: Awaited) => Promise | D, + k: (b: Awaited) => Promise | C, + l: (a: Awaited) => Promise | B, + m: AsyncFn, +): (...args: Args) => Promise>; + +export function composeAsync< + Args extends readonly unknown[], + A, + B, + C, + D, + E, + F, + G, + H, + I, +>( + f: (h_: Awaited) => Promise | I, + g: (g_: Awaited) => Promise | H, + h: (f_: Awaited) => Promise | G, + i: (e: Awaited) => Promise | F, + j: (d: Awaited) => Promise | E, + k: (c: Awaited) => Promise | D, + l: (b: Awaited) => Promise | C, + m: (a: Awaited) => Promise | B, + n: AsyncFn, +): (...args: Args) => Promise>; + +export function composeAsync< + Args extends readonly unknown[], + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, +>( + f: (i_: Awaited) => Promise | J, + g: (h_: Awaited) => Promise | I, + h: (g_: Awaited) => Promise | H, + i: (f_: Awaited) => Promise | G, + j: (e: Awaited) => Promise | F, + k: (d: Awaited) => Promise | E, + l: (c: Awaited) => Promise | D, + m: (b: Awaited) => Promise | C, + n: (a: Awaited) => Promise | B, + o: AsyncFn, +): (...args: Args) => Promise>; + +/** + * Fallback overload for async compositions exceeding 10 functions. + * Type checking between intermediate functions is not enforced. + */ +export function composeAsync( + ...fns: UnaryAsyncFunction[] +): (...args: unknown[]) => Promise; + +export function composeAsync( + ...fns: ((...args: unknown[]) => unknown)[] +): (...args: unknown[]) => Promise { const len = fns.length; - if (len > MAX_ASYNC_COMPOSITION_DEPTH) { - throw new RangeError( - `Async composition depth exceeds ${MAX_ASYNC_COMPOSITION_DEPTH}`, - ); + if (len === 0) { + throw new Error('composeAsync requires at least one function'); } - return async ( - ...args: ComposeAsyncParams - ): Promise> => { - if (len === 1) { - // biome-ignore lint/suspicious/noExplicitAny: This is intended - return await (fns[0] as any)(...(args as any[])); - } + if (len === 1) { + // biome-ignore lint/style/noNonNullAssertion: Just checked if there is atleast one + return async (...args: unknown[]) => await fns[0]!(...args); + } - // Start with rightmost function (index: len - 1) - // biome-ignore lint/suspicious/noExplicitAny: This is intended - let result = await (fns[len - 1] as any)(...(args as any[])); + return async (...args: unknown[]): Promise => { + // biome-ignore lint/style/noNonNullAssertion: I know there is at least two + let result = await fns[len - 1]!(...args); - // Iterate backwards from len - 2 to 0 for (let i = len - 2; i >= 0; i--) { - // biome-ignore lint/style/noNonNullAssertion: We know fns[i] exists because i is within [0, len-2] + // biome-ignore lint/style/noNonNullAssertion: Won't run more than it can result = await fns[i]!(result); } - return result as ComposeAsyncReturn; + return result; }; -}; +} diff --git a/packages/composition/src/compose/compose.test.ts b/packages/composition/src/compose/compose.test.ts index 2f4e382..6a9446f 100644 --- a/packages/composition/src/compose/compose.test.ts +++ b/packages/composition/src/compose/compose.test.ts @@ -92,16 +92,11 @@ describe('Compose', () => { const g = multiply2; const h = subtract1; - // Create intermediate compositions with explicit types - // in order to shut TS up. - const gh: (x: number) => number = compose(g, h); - const fg: (x: number) => number = compose(f, g); - // Left association: f ∘ (g ∘ h) - const left = compose(f, gh); + const left = compose(f, compose(g, h)); // Right association: (f ∘ g) ∘ h - const right = compose(fg, h); + const right = compose(compose(f, g), h); // Direct composition const direct = compose(f, g, h); @@ -120,13 +115,8 @@ describe('Compose', () => { const g = multiply2; const h = subtract1; - // Create intermediate compositions with explicit types - // in order to shut TS up. - const gh: (x: number) => number = compose(g, h); - const fg: (x: number) => number = compose(f, g); - - const left = compose(f, gh); - const right = compose(fg, h); + const left = compose(f, compose(g, h)); + const right = compose(compose(f, g), h); const testValues = [-10, -1, 0, 1, 5, 100, 1000]; @@ -141,13 +131,8 @@ describe('Compose', () => { const g = stringify; const h = multiply2; - // Create intermediate compositions with explicit types - // in order to shut TS up. - const gh: (x: number) => string = compose(g, h); - const fg: (x: number) => string = compose(f, g); - - const left = compose(f, gh); - const right = compose(fg, h); + const left = compose(f, compose(g, h)); + const right = compose(compose(f, g), h); const direct = compose(f, g, h); const expected = f(g(h(5))); // '10' @@ -164,14 +149,9 @@ describe('Compose', () => { const f3 = subtract1; const f4 = add3; - // Different manual groupings - const f3f4: (x: number) => number = compose(f3, f4); - const f2f3f4: (x: number) => number = compose(f2, f3f4); - const group1 = compose(f1, f2f3f4); - - const f1f2: (x: number) => number = compose(f1, f2); - const group2 = compose(f1f2, f3, f4); - + // Different groupings should produce same result + const group1 = compose(f1, compose(f2, compose(f3, f4))); + const group2 = compose(compose(f1, f2), f3, f4); const direct = compose(f1, f2, f3, f4); const testValue = 5; diff --git a/packages/composition/src/compose/index.ts b/packages/composition/src/compose/index.ts index ee51221..0fe1313 100644 --- a/packages/composition/src/compose/index.ts +++ b/packages/composition/src/compose/index.ts @@ -1,213 +1,181 @@ import type { UnaryFunction } from '../types'; /** - * Defines the valid shapes for function arrays that can be composed. + * Right-to-left variadic function composition. * - * This union type represents two possible compositions: - * 1. An array with zero or more unary functions followed by one n-ary function - * 2. A single n-ary function - * - * The `readonly` modifier ensures immutability and enables better type inference - * with rest/spread patterns. The `...UnaryFunction[]` spread allows for any number - * of unary functions to precede the final n-ary function. - */ -type CompositionArray = - // biome-ignore lint/suspicious/noExplicitAny: This is intended - | readonly [...UnaryFunction[], (...args: any[]) => any] - // biome-ignore lint/suspicious/noExplicitAny: This is intended - | readonly [(...args: any[]) => any]; - -/** - * Extracts the parameter types of the rightmost (last) function in a composition array. - * - * This type uses conditional type inference with `infer` to: - * 1. Pattern match against `readonly [...unknown[], infer Last]` to capture the last element - * 2. Check if `Last` is a function and extract its parameters with `infer P` - * 3. Return the parameter tuple type `P`, or `never` if extraction fails - * - * The `...unknown[]` spread matches any prefix elements without caring about their types, - * focusing only on the last element. This follows right-to-left composition semantics - * where the rightmost function receives the initial arguments. - * - * Example: - * ComposeParams<[(x: string) => number, (a: boolean, b: string) => string]> - * // Result: [a: boolean, b: string] - */ -type ComposeParams = Fns extends readonly [ - ...unknown[], - infer Last, -] - ? // biome-ignore lint/suspicious/noExplicitAny: This is intended - Last extends (...args: infer P) => any - ? P - : never - : never; - -/** - * Extracts the return type of the leftmost (first) function in a composition array. - * - * This type mirrors ComposeParams but focuses on the first element: - * 1. Pattern match against `readonly [infer First, ...unknown[]]` to capture the first element - * 2. Check if `First` is a function and extract its return type with `infer R` - * 3. Return the return type `R`, or `never` if extraction fails - * - * The `...unknown[]` spread ignores all elements after the first. This follows - * composition semantics where the leftmost function's return type becomes the - * overall composition's return type. - * - * Example: - * ComposeReturn<[(x: string) => number, (a: boolean) => string]> - * // Result: number - */ -type ComposeReturn = Fns extends readonly [ - infer First, - ...unknown[], -] - ? // biome-ignore lint/suspicious/noExplicitAny: This is intended - First extends (...args: any[]) => infer R - ? R - : never - : never; - -/** - * Validates and transforms a function array to ensure valid composition structure. - * - * This recursive conditional type enforces that: - * 1. Base case: A single function (of any arity) is always valid - * 2. Recursive case: The first function must be unary, and the rest must form a valid composition - * - * The constraint system works as follows: - * - `infer First extends UnaryFunction` ensures the first function is unary - * - `infer Rest extends CompositionArray` ensures the remaining functions form a valid composition - * - `readonly [First, ...Composable]` recursively validates the tail - * - * This prevents invalid compositions like having non-unary functions in non-terminal positions, - * which would break the composition chain since intermediate functions can only receive one argument. - * - * Example transformations: - * Composable<[(x: number) => string]> - * // Result: [(x: number) => string] - * - * Composable<[(x: string) => number, (a: boolean, b: string) => string]> - * // Result: [(x: string) => number, (a: boolean, b: string) => string] - * - * Invalid example (would result in `never`): - * Composable<[(a: string, b: number) => boolean, (x: boolean) => string]> - * // Error: First function is not unary - */ -type Composable = Fn extends readonly [ - // biome-ignore lint/suspicious/noExplicitAny: This is intended - (...args: any[]) => any, -] - ? Fn // Base case: single function (can be n-ary) - : Fn extends readonly [ - infer First extends UnaryFunction, - ...infer Rest extends CompositionArray, - ] - ? readonly [First, ...Composable] - : never; - -const MAX_COMPOSITION_DEPTH = 1000; - -/** - * Allows you combine two or more functions to create a new function, which passes the results from one - * function to the next until all have be called. Has a right-to-left call order. - * - * @template Fns - Tuple type of composable functions, where all but the last must be unary - * - * @param fns - The functions to compose in right-to-left order - * - * @returns A new function accepting the parameters of the rightmost function in `fns`, - * returning the return type of the leftmost function in `fns` + * Combines two or more functions to create a new function, passing the result + * from one function to the next until all have been called. The rightmost + * function is applied first to the input arguments. * * @remarks - * The implementation follows right-to-left composition semantics: - * 1. The rightmost function is applied first to the input arguments - * 2. Each subsequent function (moving left) receives the result of the previous function - * 3. The leftmost function's result becomes the final output - * - * Mathematical notation: (f ∘ g ∘ h)(x) = f(g(h(x))) + * Mathematical notation: `(f ∘ g ∘ h)(x) = f(g(h(x)))` * * Type constraints: - * - The last function can accept n parameters + * - The rightmost (last) function can accept n parameters * - All other functions must be unary (single parameter) - * - Return type of function i must be assignable to parameter of function i-1 + * - Return type of function `i` must be assignable to parameter of function `i-1` * - * Performance: Maximum composition depth is limited to 1000 functions to prevent stack overflow. + * The overload-based signature provides reliable type inference up to 10 functions. + * For compositions exceeding 10 functions, nest multiple compose calls. * * @example + * ```ts * // Mathematical composition: (uppercase ∘ stringify ∘ add3)(4) - * const transform = composeVariadic(uppercase, stringify, add3); + * const transform = compose(uppercase, stringify, add3); * transform(4); // Returns "SEVEN" * * // With n-ary rightmost function - * const sumAndStringify = composeVariadic(uppercase, stringify, (a: number, b: number) => a + b); + * const sumAndStringify = compose(uppercase, stringify, (a: number, b: number) => a + b); * sumAndStringify(3, 4); // Returns "SEVEN" + * ``` */ -export const composeVariadic = ( - ...fns: Composable -): ((...args: ComposeParams) => ComposeReturn) => { +export function compose( + f: (...args: Args) => B, +): (...args: Args) => B; + +export function compose( + f: (a: A) => B, + g: (...args: Args) => A, +): (...args: Args) => B; + +export function compose( + f: (b: B) => C, + g: (a: A) => B, + h: (...args: Args) => A, +): (...args: Args) => C; + +export function compose( + f: (c: C) => D, + g: (b: B) => C, + h: (a: A) => B, + i: (...args: Args) => A, +): (...args: Args) => D; + +export function compose( + f: (d: D) => E, + g: (c: C) => D, + h: (b: B) => C, + i: (a: A) => B, + j: (...args: Args) => A, +): (...args: Args) => E; + +export function compose( + f: (e: E) => F, + g: (d: D) => E, + h: (c: C) => D, + i: (b: B) => C, + j: (a: A) => B, + k: (...args: Args) => A, +): (...args: Args) => F; + +export function compose( + f: (f_: F) => G, + g: (e: E) => F, + h: (d: D) => E, + i: (c: C) => D, + j: (b: B) => C, + k: (a: A) => B, + l: (...args: Args) => A, +): (...args: Args) => G; + +export function compose< + Args extends readonly unknown[], + A, + B, + C, + D, + E, + F, + G, + H, +>( + f: (g_: G) => H, + g: (f_: F) => G, + h: (e: E) => F, + i: (d: D) => E, + j: (c: C) => D, + k: (b: B) => C, + l: (a: A) => B, + m: (...args: Args) => A, +): (...args: Args) => H; + +export function compose< + Args extends readonly unknown[], + A, + B, + C, + D, + E, + F, + G, + H, + I, +>( + f: (h_: H) => I, + g: (g_: G) => H, + h: (f_: F) => G, + i: (e: E) => F, + j: (d: D) => E, + k: (c: C) => D, + l: (b: B) => C, + m: (a: A) => B, + n: (...args: Args) => A, +): (...args: Args) => I; + +export function compose< + Args extends readonly unknown[], + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, +>( + f: (i_: I) => J, + g: (h_: H) => I, + h: (g_: G) => H, + i: (f_: F) => G, + j: (e: E) => F, + k: (d: D) => E, + l: (c: C) => D, + m: (b: B) => C, + n: (a: A) => B, + o: (...args: Args) => A, +): (...args: Args) => J; + +/** + * Fallback overload for compositions exceeding 10 functions. + * Type checking between intermediate functions is not enforced. + */ +export function compose(...fns: UnaryFunction[]): UnaryFunction; + +export function compose( + ...fns: ((...args: unknown[]) => unknown)[] +): (...args: unknown[]) => unknown { const len = fns.length; - if (len > MAX_COMPOSITION_DEPTH) { - throw new RangeError(`Composition depth exceeds ${MAX_COMPOSITION_DEPTH}`); + if (len === 0) { + throw new Error('compose requires at least one function'); } - return (...args: ComposeParams): ComposeReturn => { - if (len === 1) { - // biome-ignore lint/suspicious/noExplicitAny: This is intended - return (fns[0] as any)(...(args as any[])) as ComposeReturn; - } + if (len === 1) { + // biome-ignore lint/style/noNonNullAssertion: Just checked if there is atleast one + return fns[0]!; + } - // Start with rightmost function (index: len - 1) - // biome-ignore lint/suspicious/noExplicitAny: This is intended - let result = (fns[len - 1] as any)(...(args as any[])); + return (...args: unknown[]): unknown => { + // biome-ignore lint/style/noNonNullAssertion: I know there is at least two + let result = fns[len - 1]!(...args); - // Iterate backwards from len - 2 to 0 for (let i = len - 2; i >= 0; i--) { - // biome-ignore lint/style/noNonNullAssertion: We know fns[i] exists because i is within [0, len-2] + // biome-ignore lint/style/noNonNullAssertion: Won't run more than it can result = fns[i]!(result); } - return result as ComposeReturn; + return result; }; -}; - -/** - * @alias composeVariadic - * - * Allows you combine two or more functions to create a new function, which passes the results from one - * function to the next until all have be called. Has a right-to-left call order. - * - * @template Fns - Tuple type of composable functions, where all but the last must be unary - * - * @param fns - The functions to compose in right-to-left order - * - * @returns A new function accepting the parameters of the rightmost function in `fns`, - * returning the return type of the leftmost function in `fns` - * - * @remarks - * The implementation follows right-to-left composition semantics: - * 1. The rightmost function is applied first to the input arguments - * 2. Each subsequent function (moving left) receives the result of the previous function - * 3. The leftmost function's result becomes the final output - * - * Mathematical notation: (f ∘ g ∘ h)(x) = f(g(h(x))) - * - * Type constraints: - * - The last function can accept n parameters - * - All other functions must be unary (single parameter) - * - Return type of function i must be assignable to parameter of function i-1 - * - * Performance: Maximum composition depth is limited to 1000 functions to prevent stack overflow. - * - * @example - * // Mathematical composition: (uppercase ∘ stringify ∘ add3)(4) - * const transform = compose(uppercase, stringify, add3); - * transform(4); // Returns "SEVEN" - * - * // With n-ary rightmost function - * const sumAndStringify = compose(uppercase, stringify, (a: number, b: number) => a + b); - * sumAndStringify(3, 4); // Returns "SEVEN" - */ -export const compose = composeVariadic; +} diff --git a/packages/composition/src/curry/curry.bench.ts b/packages/composition/src/curry/curry.bench.ts index 7edcd7b..454fd86 100644 --- a/packages/composition/src/curry/curry.bench.ts +++ b/packages/composition/src/curry/curry.bench.ts @@ -1,29 +1,378 @@ import { bench, describe } from 'vitest'; -import { autoCurry, type Curried } from '.'; +import { curry } from '.'; -// biome-ignore lint/suspicious/noExplicitAny: This is intended -function spreadCurry any>( +// Implementations +// ============================================================ + +type Curried =

>( + ...args: P + // biome-ignore lint/suspicious/noExplicitAny: This is intended +) => ((...args: T) => any) extends (...args: [...P, ...infer Args]) => any + ? Args extends [] + ? R + : Curried + : never; + +/** + * Ramda-style implementation for library comparison. + * Common pattern in FP libraries. + */ +function curryRamda unknown>( + fn: T, +): (...args: unknown[]) => unknown { + const arity = fn.length; + return function curried(...args: unknown[]): unknown { + if (args.length >= arity) { + return fn(...args); + } + return (...more: unknown[]) => curried(...args, ...more); + }; +} + +/** + * Original implementation for comparison baseline. + * Uses array accumulation with concat. + */ + +// biome-ignore lint/suspicious/noExplicitAny: Benchmarks +function curryOriginal any>( fn: T, // biome-ignore lint/suspicious/noExplicitAny: This is intended _args = [] as any[], ): Curried, ReturnType> { - return (...__args) => - ((rest) => (rest.length >= fn.length ? fn(...rest) : autoCurry(fn, rest)))([ - ..._args, - ...__args, - ]); + return (...__args) => { + const argsLen = _args.length; + const newArgsLen = __args.length; + const totalLen = argsLen + newArgsLen; + + // Only spread when executing, not on partial application + if (totalLen >= fn.length) { + // Pre-allocate exact size + const allArgs = new Array(totalLen); + + // Manual copy is faster than spread for small arrays + for (let i = 0; i < argsLen; i++) { + allArgs[i] = _args[i]; + } + + for (let i = 0; i < newArgsLen; i++) { + allArgs[argsLen + i] = __args[i]; + } + + return fn(...allArgs); + } + + // For partial application, concat is faster than spread for this use case + return curryOriginal(fn, _args.concat(__args)); + }; } -const addAndMultiply = (a: number, b: number, c: number) => (a + b) * c; -const curriedSpread = spreadCurry(addAndMultiply); -const curriedIterate = autoCurry(addAndMultiply); +// Test Functions +// ============================================================ -describe('curry', () => { - bench('spread curry', () => { - const _r = curriedSpread(2)(3)(4); +const add2 = (a: number, b: number): number => a + b; +const add3 = (a: number, b: number, c: number): number => a + b + c; +const add5 = (a: number, b: number, c: number, d: number, e: number): number => + a + b + c + d + e; +const add6 = ( + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, +): number => a + b + c + d + e + f; + +// Benchmarks +// ============================================================ + +describe('Curry Creation', () => { + describe('Binary', () => { + bench('optimized: curry(add2)', () => { + curry(add2); + }); + + bench('original: curry(add2)', () => { + curryOriginal(add2); + }); + + bench('ramda-style: curry(add2)', () => { + // @ts-expect-error Not going to worry about it in a benchmark + curryRamda(add2); + }); }); - bench('iterate curry', () => { - const _r = curriedIterate(2)(3)(4); + describe('Ternary', () => { + bench('optimized: curry(add3)', () => { + curry(add3); + }); + + bench('original: curry(add3)', () => { + curryOriginal(add3); + }); + + bench('ramda-style: curry(add3)', () => { + // @ts-expect-error Not going to worry about it in a benchmark + curryRamda(add3); + }); + }); + + describe('Quinary', () => { + bench('optimized: curry(add5)', () => { + curry(add5); + }); + + bench('original: curry(add5)', () => { + curryOriginal(add5); + }); + + bench('ramda-style: curry(add5)', () => { + // @ts-expect-error Not going to worry about it in a benchmark + curryRamda(add5); + }); + }); +}); + +describe('Full Application', () => { + describe('Ternary', () => { + const optAdd3 = curry(add3); + const origAdd3 = curryOriginal(add3); + // @ts-expect-error Benchmark + const ramdaAdd3 = curryRamda(add3); + + bench('optimized: f(1, 2, 3)', () => { + optAdd3(1, 2, 3); + }); + + bench('original: f(1, 2, 3)', () => { + origAdd3(1, 2, 3); + }); + + bench('ramda-style: f(1, 2, 3)', () => { + ramdaAdd3(1, 2, 3); + }); + }); + + describe('Quinary', () => { + const optAdd5 = curry(add5); + const origAdd5 = curryOriginal(add5); + // @ts-expect-error Benchmark + const ramdaAdd5 = curryRamda(add5); + + bench('optimized: f(1, 2, 3, 4, 5)', () => { + optAdd5(1, 2, 3, 4, 5); + }); + + bench('original: f(1, 2, 3, 4, 5)', () => { + origAdd5(1, 2, 3, 4, 5); + }); + + bench('ramda-style: f(1, 2, 3, 4, 5)', () => { + ramdaAdd5(1, 2, 3, 4, 5); + }); + }); +}); + +describe('Partial Application', () => { + describe('f(a)(b, c)', () => { + const optAdd3 = curry(add3); + const origAdd3 = curryOriginal(add3); + // @ts-expect-error Benchmark + const ramdaAdd3 = curryRamda(add3); + + bench('optimized: f(1)(2, 3)', () => { + optAdd3(1)(2, 3); + }); + + bench('original: f(1)(2, 3)', () => { + origAdd3(1)(2, 3); + }); + + bench('ramda-style: f(1)(2, 3)', () => { + // @ts-expect-error Benchmark + ramdaAdd3(1)(2, 3); + }); + }); + + describe('f(a, b)(c)', () => { + const optAdd3 = curry(add3); + const origAdd3 = curryOriginal(add3); + // @ts-expect-error Benchmark + const ramdaAdd3 = curryRamda(add3); + + bench('optimized: f(1, 2)(3)', () => { + optAdd3(1, 2)(3); + }); + + bench('original: f(1, 2)(3)', () => { + origAdd3(1, 2)(3); + }); + + bench('ramda-style: f(1, 2)(3)', () => { + // @ts-expect-error Benchmark + ramdaAdd3(1, 2)(3); + }); + }); +}); + +describe('Fully Curried', () => { + describe('Ternary', () => { + const optAdd3 = curry(add3); + const origAdd3 = curryOriginal(add3); + // @ts-expect-error Benchmark + const ramdaAdd3 = curryRamda(add3); + + bench('optimized: f(1)(2)(3)', () => { + optAdd3(1)(2)(3); + }); + + bench('original: f(1)(2)(3)', () => { + origAdd3(1)(2)(3); + }); + + bench('ramda-style: f(1)(2)(3)', () => { + // @ts-expect-error Benchmark + ramdaAdd3(1)(2)(3); + }); + }); + + describe('Quinary', () => { + const optAdd5 = curry(add5); + const origAdd5 = curryOriginal(add5); + // @ts-expect-error Benchmark + const ramdaAdd5 = curryRamda(add5); + + bench('optimized: f(1)(2)(3)(4)(5)', () => { + optAdd5(1)(2)(3)(4)(5); + }); + + bench('original: f(1)(2)(3)(4)(5)', () => { + origAdd5(1)(2)(3)(4)(5); + }); + + bench('ramda-style: f(1)(2)(3)(4)(5)', () => { + // @ts-expect-error Benchmark + ramdaAdd5(1)(2)(3)(4)(5); + }); + }); + + describe('Senary', () => { + const optAdd6 = curry(add6); + const origAdd6 = curryOriginal(add6); + // @ts-expect-error Benchmark + const ramdaAdd6 = curryRamda(add6); + + bench('optimized: f(1)(2)(3)(4)(5)(6)', () => { + optAdd6(1)(2)(3)(4)(5)(6); + }); + + bench('original: f(1)(2)(3)(4)(5)(6)', () => { + origAdd6(1)(2)(3)(4)(5)(6); + }); + + bench('ramda-style: f(1)(2)(3)(4)(5)(6)', () => { + // @ts-expect-error Benchmark + ramdaAdd6(1)(2)(3)(4)(5)(6); + }); + }); +}); + +describe('Reused Partial: Common Use', () => { + describe('f(1)(2, 3)', () => { + const optAdd10 = curry(add3)(10); + const origAdd10 = curryOriginal(add3)(10); + // @ts-expect-error Benchmark + const ramdaAdd10 = curryRamda(add3)(10); + + bench('optimized: add10(5, 3)', () => { + optAdd10(5, 3); + }); + + bench('original: add10(5, 3)', () => { + origAdd10(5, 3); + }); + + bench('ramda-style: add10(5, 3)', () => { + // @ts-expect-error Benchmark + ramdaAdd10(5, 3); + }); + }); + + describe('f(1)(2)(3)', () => { + const optAdd10 = curry(add3)(10); + const origAdd10 = curryOriginal(add3)(10); + // @ts-expect-error Benchmark + const ramdaAdd10 = curryRamda(add3)(10); + + bench('optimized: add10(5)(3)', () => { + optAdd10(5)(3); + }); + + bench('original: add10(5)(3)', () => { + origAdd10(5)(3); + }); + + bench('ramda-style: add10(5)(3)', () => { + // @ts-expect-error Benchmark + ramdaAdd10(5)(3); + }); + }); +}); + +describe('Immediate Execution', () => { + describe('Complete', () => { + bench('optimized: curry(add3)(1, 2, 3)', () => { + curry(add3)(1, 2, 3); + }); + + bench('original: curry(add3)(1, 2, 3)', () => { + curryOriginal(add3)(1, 2, 3); + }); + + bench('ramda-style: curry(add3)(1, 2, 3)', () => { + // @ts-expect-error Benchmark + curryRamda(add3)(1, 2, 3); + }); + }); + + describe('Full Curried', () => { + bench('optimized: curry(add3)(1)(2)(3)', () => { + curry(add3)(1)(2)(3); + }); + + bench('original: curry(add3)(1)(2)(3)', () => { + curryOriginal(add3)(1)(2)(3); + }); + + bench('ramda-style: curry(add3)(1)(2)(3)', () => { + // @ts-expect-error Benchmark + curryRamda(add3)(1)(2)(3); + }); + }); +}); + +describe('Uncommon Use', () => { + describe('Nullary', () => { + const nullary = (): number => 42; + + bench('optimized: nullary function (passthrough)', () => { + curry(nullary)(); + }); + + bench('original: nullary function', () => { + curryOriginal(nullary)(); + }); + }); + + describe('Unary', () => { + const identity = (x: number): number => x; + + bench('optimized: unary function (passthrough)', () => { + curry(identity)(42); + }); + + bench('original: unary function', () => { + curryOriginal(identity)(42); + }); }); }); diff --git a/packages/composition/src/curry/curry.test.ts b/packages/composition/src/curry/curry.test.ts index 539644c..f350dd7 100644 --- a/packages/composition/src/curry/curry.test.ts +++ b/packages/composition/src/curry/curry.test.ts @@ -1,14 +1,274 @@ import { describe, expect, it } from 'vitest'; import { curry } from '.'; -const addAndMultiply = (a: number, b: number, c: number) => (a + b) * c; -const curriedFn = curry(addAndMultiply); - describe('Curry', () => { - it('should correctly allow any combination of parameters', () => { - expect(curriedFn(2)(3)(4)).toEqual(20); - expect(curriedFn(2, 3)(4)).toEqual(20); - expect(curriedFn(2)(3, 4)).toEqual(20); - expect(curriedFn(2, 3, 4)).toEqual(20); + describe('Arity Constraints', () => { + it('should throw RangeError for functions with >6 parameters', () => { + const fn = ( + a: unknown, + b: unknown, + c: unknown, + d: unknown, + e: unknown, + f: unknown, + g: unknown, + ) => `${a}${b}${c}${d}${e}${f}${g}`; + + // @ts-expect-error For testing + expect(() => curry(fn)).toThrow(RangeError); + // @ts-expect-error For testing + expect(() => curry(fn)).toThrow( + 'curry only supports functions with 0-6 parameters', + ); + }); + + it('should include the actual arity in error message', () => { + const fn = ( + a: unknown, + b: unknown, + c: unknown, + d: unknown, + e: unknown, + f: unknown, + g: unknown, + h: unknown, + ) => `${a}${b}${c}${d}${e}${f}${g}${h}`; + + // @ts-expect-error For testing + expect(() => curry(fn)).toThrow('Received function with 8 parameters'); + }); + }); + + describe('Arity 0 (passthrough)', () => { + const nullary = (): number => 42; + const curried = curry(nullary); + + it('should return the function as-is', () => { + expect(curried()).toBe(42); + }); + }); + + describe('Arity 1 (passthrough)', () => { + const identity = (x: number): number => x; + const curried = curry(identity); + + it('should return the function as-is', () => { + expect(curried(42)).toBe(42); + expect(curried(-1)).toBe(-1); + }); + }); + + describe('Arity 2', () => { + const add = (a: number, b: number): number => a + b; + const curried = curry(add); + + it('should support f(a, b)', () => { + expect(curried(1, 2)).toBe(3); + }); + + it('should support f(a)(b)', () => { + expect(curried(1)(2)).toBe(3); + }); + + it('should support partial application reuse', () => { + const add10 = curried(10); + expect(add10(5)).toBe(15); + expect(add10(20)).toBe(30); + }); + }); + + describe('Arity 3', () => { + const add = (a: number, b: number, c: number): number => a + b + c; + const curried = curry(add); + + it('should support f(a, b, c)', () => { + expect(curried(1, 2, 3)).toBe(6); + }); + + it('should support f(a)(b)(c)', () => { + expect(curried(1)(2)(3)).toBe(6); + }); + + it('should support f(a, b)(c)', () => { + expect(curried(1, 2)(3)).toBe(6); + }); + + it('should support f(a)(b, c)', () => { + expect(curried(1)(2, 3)).toBe(6); + }); + + it('should support partial application reuse', () => { + const add10 = curried(10); + expect(add10(5, 3)).toBe(18); + expect(add10(5)(3)).toBe(18); + + const add15 = add10(5); + expect(add15(3)).toBe(18); + expect(add15(7)).toBe(22); + }); + }); + + describe('Arity 4', () => { + const add = (a: number, b: number, c: number, d: number): number => + a + b + c + d; + const curried = curry(add); + + it('should support f(a, b, c, d)', () => { + expect(curried(1, 2, 3, 4)).toBe(10); + }); + + it('should support f(a)(b)(c)(d)', () => { + expect(curried(1)(2)(3)(4)).toBe(10); + }); + + it('should support f(a, b)(c, d)', () => { + expect(curried(1, 2)(3, 4)).toBe(10); + }); + + it('should support f(a)(b, c)(d)', () => { + expect(curried(1)(2, 3)(4)).toBe(10); + }); + + it('should support f(a, b, c)(d)', () => { + expect(curried(1, 2, 3)(4)).toBe(10); + }); + + it('should support f(a)(b)(c, d)', () => { + expect(curried(1)(2)(3, 4)).toBe(10); + }); + + it('should support partial application reuse', () => { + const add10 = curried(10); + const add15 = add10(5); + const add18 = add15(3); + + expect(add18(2)).toBe(20); + expect(add15(3, 2)).toBe(20); + expect(add10(5, 3, 2)).toBe(20); + }); + }); + + describe('Arity 5', () => { + const add = ( + a: number, + b: number, + c: number, + d: number, + e: number, + ): number => a + b + c + d + e; + const curried = curry(add); + + it('should support f(a, b, c, d, e)', () => { + expect(curried(1, 2, 3, 4, 5)).toBe(15); + }); + + it('should support f(a)(b)(c)(d)(e)', () => { + expect(curried(1)(2)(3)(4)(5)).toBe(15); + }); + + it('should support f(a, b)(c, d)(e)', () => { + expect(curried(1, 2)(3, 4)(5)).toBe(15); + }); + + it('should support f(a)(b, c, d, e)', () => { + expect(curried(1)(2, 3, 4, 5)).toBe(15); + }); + + it('should support partial application reuse', () => { + const add10 = curried(10); + expect(add10(1)(2)(3)(4)).toBe(20); + expect(add10(1, 2)(3, 4)).toBe(20); + expect(add10(1, 2, 3, 4)).toBe(20); + }); + }); + + describe('Arity 6', () => { + const add = ( + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, + ): number => a + b + c + d + e + f; + const curried = curry(add); + + it('should support f(a, b, c, d, e, f)', () => { + expect(curried(1, 2, 3, 4, 5, 6)).toBe(21); + }); + + it('should support f(a)(b)(c)(d)(e)(f)', () => { + expect(curried(1)(2)(3)(4)(5)(6)).toBe(21); + }); + + it('should support f(a, b, c)(d, e, f)', () => { + expect(curried(1, 2, 3)(4, 5, 6)).toBe(21); + }); + + it('should support partial application reuse', () => { + const add10 = curried(10); + expect(add10(1)(2)(3)(4)(5)).toBe(25); + expect(add10(1, 2, 3, 4, 5)).toBe(25); + }); + }); + + describe('Preserves `this` Context', () => { + it('should work with methods when bound', () => { + const obj = { + multiplier: 10, + multiply(a: number, b: number): number { + return this.multiplier * a * b; + }, + }; + + const boundMultiply = obj.multiply.bind(obj); + const curried = curry(boundMultiply); + + expect(curried(2, 3)).toBe(60); + expect(curried(2)(3)).toBe(60); + }); + }); + + describe('Type Preservation', () => { + it('should preserve return types', () => { + const concat = (a: string, b: string): string => a + b; + const curried = curry(concat); + + const result: string = curried('hello')(' world'); + expect(result).toBe('hello world'); + }); + + it('should work with complex types', () => { + type User = { name: string; age: number }; + + const createUser = (name: string, age: number): User => ({ name, age }); + const curried = curry(createUser); + + const result = curried('Alice')(30); + expect(result).toEqual({ name: 'Alice', age: 30 }); + }); + + it('should work with array types', () => { + const zip = (a: T[], b: U[]): [T, U][] => + a.map((x, i) => [x, b[i]] as [T, U]); + + const curried = curry(zip); + const result = curried([1, 2, 3])(['a', 'b', 'c']); + + expect(result).toEqual([ + [1, 'a'], + [2, 'b'], + [3, 'c'], + ]); + }); + + it('should work with null and undefined args', () => { + const fn = (a: number | null, b: number | undefined): string => + `${a}-${b}`; + const curried = curry(fn); + + expect(curried(null)(undefined)).toBe('null-undefined'); + expect(curried(null, undefined)).toBe('null-undefined'); + }); }); }); diff --git a/packages/composition/src/curry/index.ts b/packages/composition/src/curry/index.ts index b95978f..7a939d4 100644 --- a/packages/composition/src/curry/index.ts +++ b/packages/composition/src/curry/index.ts @@ -1,123 +1,272 @@ -export type Curried =

>( - ...args: P - // biome-ignore lint/suspicious/noExplicitAny: This is intended -) => ((...args: T) => any) extends (...args: [...P, ...infer Args]) => any - ? Args extends [] - ? R - : Curried - : never; - +/** biome-ignore-all lint/complexity/noArguments: This is special, hush now */ +/** biome-ignore-all lint/complexity/useArrowFunction: To preserve `this` */ /** - * Curries the given function. Allowing it to accept one or more arguments at a time. - * - * @template T - The function to be curried. - * - * @param fn - The function to convert to a curried version - * - * @returns A curried function that can accept parameters incrementally. - * Each partial application returns either: - * - Another curried function if more parameters needed - * - The final result R if all parameters satisfied - * - * @remarks - * The curried function maintains referential transparency and can be called with: - * - One argument at a time: autoCurry(f)(a)(b)(c) - * - Multiple arguments: autoCurry(f)(a, b)(c) - * - All arguments: autoCurry(f)(a, b, c) - * - Any combination of the above - * - * Arity detection uses Function.prototype.length which counts only non-rest parameters. - * - * @example - * const multiply = (a: number, b: number, c: number): number => a * b * c; - * const curriedMultiply = autoCurry(multiply); - * - * // All equivalent: - * curriedMultiply(2)(3)(4); // 24 - * curriedMultiply(2, 3)(4); // 24 - * curriedMultiply(2)(3, 4); // 24 - * curriedMultiply(2, 3, 4); // 24 - * - * // Partial application for reuse - * const add = (a: number, b: number, c: number) => a + b + c; - * const curriedAdd = autoCurry(add); - * const add10 = curriedAdd(10); - * - * add10(5, 3); // 18 - * add10(2, 8); // 20 + * Curried function type for 2-arity functions. */ -// biome-ignore lint/suspicious/noExplicitAny: This is intended -export function autoCurry any>( - fn: T, - // biome-ignore lint/suspicious/noExplicitAny: This is intended - _args = [] as any[], -): Curried, ReturnType> { - return (...__args) => { - const argsLen = _args.length; - const newArgsLen = __args.length; - const totalLen = argsLen + newArgsLen; - - // Only spread when executing, not on partial application - if (totalLen >= fn.length) { - // Pre-allocate exact size - const allArgs = new Array(totalLen); +type Curried2 = { + (a: A): (b: B) => R; + (a: A, b: B): R; +}; - // Manual copy is faster than spread for small arrays - for (let i = 0; i < argsLen; i++) { - allArgs[i] = _args[i]; - } +/** + * Curried function type for 3-arity functions. + */ +type Curried3 = { + (a: A): Curried2; + (a: A, b: B): (c: C) => R; + (a: A, b: B, c: C): R; +}; - for (let i = 0; i < newArgsLen; i++) { - allArgs[argsLen + i] = __args[i]; - } +/** + * Curried function type for 4-arity functions. + */ +type Curried4 = { + (a: A): Curried3; + (a: A, b: B): Curried2; + (a: A, b: B, c: C): (d: D) => R; + (a: A, b: B, c: C, d: D): R; +}; - return fn(...allArgs); - } +/** + * Curried function type for 5-arity functions. + */ +type Curried5 = { + (a: A): Curried4; + (a: A, b: B): Curried3; + (a: A, b: B, c: C): Curried2; + (a: A, b: B, c: C, d: D): (e: E) => R; + (a: A, b: B, c: C, d: D, e: E): R; +}; - // For partial application, concat is faster than spread for this use case - return autoCurry(fn, _args.concat(__args)); - }; -} +/** + * Curried function type for 6-arity functions. + */ +type Curried6 = { + (a: A): Curried5; + (a: A, b: B): Curried4; + (a: A, b: B, c: C): Curried3; + (a: A, b: B, c: C, d: D): Curried2; + (a: A, b: B, c: C, d: D, e: E): (f: F) => R; + (a: A, b: B, c: C, d: D, e: E, f: F): R; +}; /** - * @alias autoCurry - * - * Curries the given function. Allowing it to accept one or more arguments at a time. - * - * @template T - The function to be curried. + * Curries the given function, allowing it to accept one or more arguments at a time. * * @param fn - The function to convert to a curried version * * @returns A curried function that can accept parameters incrementally. * Each partial application returns either: - * - Another curried function if more parameters needed - * - The final result R if all parameters satisfied + * - Another curried function if more parameters are needed + * - The final result if all parameters are satisfied * * @remarks - * The curried function maintains referential transparency and can be called with: - * - One argument at a time: curry(f)(a)(b)(c) - * - Multiple arguments: curry(f)(a, b)(c) - * - All arguments: curry(f)(a, b, c) - * - Any combination of the above + * This is an "auto-curry" implementation that allows multiple arguments per call, + * unlike traditional Haskell-style currying which accepts exactly one argument at a time. * - * Arity detection uses Function.prototype.length which counts only non-rest parameters. + * The implementation uses arity-specialized code paths for functions with 0-6 parameters + * to avoid array allocation and achieve optimal performance. + * + * Arity detection uses `Function.prototype.length` which counts only parameters + * before the first one with a default value or rest parameter. * * @example + * ```ts * const multiply = (a: number, b: number, c: number): number => a * b * c; * const curriedMultiply = curry(multiply); * - * // All equivalent: - * curriedMultiply(2)(3)(4); // 24 - * curriedMultiply(2, 3)(4); // 24 - * curriedMultiply(2)(3, 4); // 24 - * curriedMultiply(2, 3, 4); // 24 + * // All equivalent - returns 24: + * curriedMultiply(2)(3)(4); + * curriedMultiply(2, 3)(4); + * curriedMultiply(2)(3, 4); + * curriedMultiply(2, 3, 4); * * // Partial application for reuse - * const add = (a: number, b: number, c: number) => a + b + c; - * const curriedAdd = curry(add); - * const add10 = curriedAdd(10); - * - * add10(5, 3); // 18 - * add10(2, 8); // 20 + * const double = curriedMultiply(2)(1); + * double(5); // 10 + * double(10); // 20 + * ``` */ -export const curry = autoCurry; +export function curry(fn: () => R): () => R; +export function curry(fn: (a: A) => R): (a: A) => R; +export function curry(fn: (a: A, b: B) => R): Curried2; +export function curry( + fn: (a: A, b: B, c: C) => R, +): Curried3; +export function curry( + fn: (a: A, b: B, c: C, d: D) => R, +): Curried4; +export function curry( + fn: (a: A, b: B, c: C, d: D, e: E) => R, +): Curried5; + +export function curry( + fn: (a: A, b: B, c: C, d: D, e: E, f: F) => R, +): Curried6; + +// Curried7... +// NOTE: If you reach this point, you need to refactor your function +// Probably should have before getting, tbh. + +export function curry( + fn: (...args: unknown[]) => unknown, +): (...args: unknown[]) => unknown { + switch (fn.length) { + case 0: + case 1: + return fn; + + case 2: + return function c2(a: unknown, b: unknown): unknown { + return arguments.length >= 2 + ? fn(a, b) + : function (_b: unknown): unknown { + return fn(a, _b); + }; + }; + + case 3: + return function c3(a: unknown, b: unknown, c: unknown): unknown { + switch (arguments.length) { + case 1: + return function c2(_b: unknown, _c: unknown): unknown { + return arguments.length >= 2 + ? fn(a, _b, _c) + : function (__c: unknown): unknown { + return fn(a, _b, __c); + }; + }; + case 2: + return function (_c: unknown): unknown { + return fn(a, b, _c); + }; + default: + return fn(a, b, c); + } + }; + + case 4: + return function c4( + a: unknown, + b: unknown, + c: unknown, + d: unknown, + ): unknown { + switch (arguments.length) { + case 1: + return curry(function ( + _b: unknown, + _c: unknown, + _d: unknown, + ): unknown { + return fn(a, _b, _c, _d); + }); + case 2: + return curry(function (_c: unknown, _d: unknown): unknown { + return fn(a, b, _c, _d); + }); + case 3: + return function (_d: unknown): unknown { + return fn(a, b, c, _d); + }; + default: + return fn(a, b, c, d); + } + }; + + case 5: + return function c5( + a: unknown, + b: unknown, + c: unknown, + d: unknown, + e: unknown, + ): unknown { + switch (arguments.length) { + case 1: + return curry(function ( + _b: unknown, + _c: unknown, + _d: unknown, + _e: unknown, + ): unknown { + return fn(a, _b, _c, _d, _e); + }); + case 2: + return curry(function ( + _c: unknown, + _d: unknown, + _e: unknown, + ): unknown { + return fn(a, b, _c, _d, _e); + }); + case 3: + return curry(function (_d: unknown, _e: unknown): unknown { + return fn(a, b, c, _d, _e); + }); + case 4: + return function (_e: unknown): unknown { + return fn(a, b, c, d, _e); + }; + default: + return fn(a, b, c, d, e); + } + }; + + case 6: + return function c6( + a: unknown, + b: unknown, + c: unknown, + d: unknown, + e: unknown, + f: unknown, + ): unknown { + switch (arguments.length) { + case 1: + return curry(function ( + _b: unknown, + _c: unknown, + _d: unknown, + _e: unknown, + _f: unknown, + ): unknown { + return fn(a, _b, _c, _d, _e, _f); + }); + case 2: + return curry(function ( + _c: unknown, + _d: unknown, + _e: unknown, + _f: unknown, + ): unknown { + return fn(a, b, _c, _d, _e, _f); + }); + case 3: + return curry(function ( + _d: unknown, + _e: unknown, + _f: unknown, + ): unknown { + return fn(a, b, c, _d, _e, _f); + }); + case 4: + return curry(function (_e: unknown, _f: unknown): unknown { + return fn(a, b, c, d, _e, _f); + }); + case 5: + return function (_f: unknown): unknown { + return fn(a, b, c, d, e, _f); + }; + default: + return fn(a, b, c, d, e, f); + } + }; + + default: + throw new RangeError( + `curry only supports functions with 0-6 parameters. Received function with ${fn.length} parameters. ` + + 'Consider refactoring to use an options object or composing multiple curried functions.', + ); + } +} diff --git a/packages/composition/src/index.ts b/packages/composition/src/index.ts index a696609..9ce3edd 100644 --- a/packages/composition/src/index.ts +++ b/packages/composition/src/index.ts @@ -1,7 +1,6 @@ -export { compose, composeVariadic } from './compose'; +export { compose } from './compose'; export { composeAsync } from './compose-async'; -export { autoCurry, curry } from './curry'; -export { pipe, pipeVariadic } from './pipe'; +export { curry } from './curry'; +export { pipe } from './pipe'; export { pipeAsync } from './pipe-async'; -export type { Curried } from './curry'; export type { UnaryAsyncFunction, UnaryFunction } from './types'; diff --git a/packages/composition/src/pipe-async/index.ts b/packages/composition/src/pipe-async/index.ts index 05bce15..328d83b 100644 --- a/packages/composition/src/pipe-async/index.ts +++ b/packages/composition/src/pipe-async/index.ts @@ -1,94 +1,34 @@ import type { UnaryAsyncFunction } from '../types'; /** - * Defines the valid shapes for async function arrays that can be piped. + * Async function type that can return either a Promise or a synchronous value. */ -type AsyncPipeArray = - // biome-ignore lint/suspicious/noExplicitAny: This is intended - | readonly [(...args: any[]) => Promise | any, ...UnaryAsyncFunction[]] - // biome-ignore lint/suspicious/noExplicitAny: This is intended - | readonly [(...args: any[]) => Promise | any]; +type AsyncFn = ( + ...args: Args +) => Promise | R; /** - * Extracts the parameter types of the leftmost (first) function in an async pipe array. - */ -type PipeAsyncParams = Fns extends readonly [ - infer First, - ...unknown[], -] - ? // biome-ignore lint/suspicious/noExplicitAny: This is intended - First extends (...args: infer P) => any - ? P - : never - : never; - -/** - * Extracts the awaited return type of the rightmost (last) function in an async pipe array. - */ -type PipeAsyncReturn = Fns extends readonly [ - ...unknown[], - infer Last, -] - ? // biome-ignore lint/suspicious/noExplicitAny: This is intended - Last extends (...args: any[]) => infer R - ? Awaited - : never - : never; - -/** - * Validates and transforms an async function array to ensure valid pipe structure. - */ -type PipeableAsync = Fn extends readonly [ - // biome-ignore lint/suspicious/noExplicitAny: This is intended - (...args: any[]) => any, -] - ? Fn - : Fn extends readonly [ - // biome-ignore lint/suspicious/noExplicitAny: This is intended - infer First extends (...args: any[]) => any, - ...infer Rest extends readonly UnaryAsyncFunction[], - ] - ? readonly [First, ...Rest] - : never; - -const MAX_ASYNC_PIPE_DEPTH = 1000; - -/** - * Pipes async functions from left to right.. - * - * @template Fns - Tuple type of pipeable async functions, where the first can be n-ary - * and all subsequent functions must be unary + * Left-to-right variadic async function composition (piping). * - * @param fns - The functions to pipe in left-to-right order - * - * @returns A new async function accepting the parameters of the leftmost function in `fns`, - * returning a Promise of the return type of the rightmost function in `fns` + * Combines two or more functions (sync or async) to create a new async function, + * passing the awaited result from one function to the next until all have been called. + * The leftmost function is applied first to the input arguments. * * @remarks - * The implementation follows left-to-right pipe semantics: - * 1. The leftmost function is applied first to the input arguments - * 2. Each subsequent function (moving right) receives the awaited result of the previous function - * 3. The rightmost function's result becomes the final output - * - * Mathematical notation: pipe(f, g, h)(x) = await h(await g(await f(x))) + * Mathematical notation: `(f | g | h)(x) = await h(await g(await f(x)))` * * Type constraints: - * - The first function can accept n parameters + * - The leftmost (first) function can accept n parameters * - All other functions must be unary (single parameter) - * - Functions can return Promise or T (sync functions auto-wrapped) - * - Final return type is always Promise + * - Functions can return `Promise` or `T` (sync functions auto-wrapped) + * - Final return type is always `Promise` + * - Return type of function `i` must be assignable to parameter of function `i+1` * - * Performance: O(n) time complexity, O(1) space complexity where n is the number of functions. - * - * @example - * const fetchData = async (url: string): Promise => { ... }; - * const parseJSON = async (response: Response): Promise => { ... }; - * const transform = (data: Data): Result => { ... }; - * - * const processUrl = pipeAsync(fetchData, parseJSON, transform); - * await processUrl('https://api.example.com/data'); + * The overload-based signature provides reliable type inference up to 10 functions. + * For pipes exceeding 10 functions, nest multiple pipeAsync calls. * * @example + * ```ts * // Mix of sync and async functions * const process = pipeAsync( * async (x: number) => x + 3, // async @@ -96,34 +36,165 @@ const MAX_ASYNC_PIPE_DEPTH = 1000; * async (x: string) => x.toUpperCase() // async * ); * await process(4); // "7" + * + * // With n-ary leftmost function + * const fetchAndProcess = pipeAsync( + * async (url: string, options: RequestInit) => fetch(url, options), + * (response: Response) => response.json(), + * (data: unknown) => processData(data) + * ); + * await fetchAndProcess('https://api.example.com', { method: 'GET' }); + * ``` + */ +export function pipeAsync( + f: AsyncFn, +): (...args: Args) => Promise>; + +export function pipeAsync( + f: AsyncFn, + g: (a: Awaited) => Promise | B, +): (...args: Args) => Promise>; + +export function pipeAsync( + f: AsyncFn, + g: (a: Awaited) => Promise | B, + h: (b: Awaited) => Promise | C, +): (...args: Args) => Promise>; + +export function pipeAsync( + f: AsyncFn, + g: (a: Awaited) => Promise | B, + h: (b: Awaited) => Promise | C, + i: (c: Awaited) => Promise | D, +): (...args: Args) => Promise>; + +export function pipeAsync( + f: AsyncFn, + g: (a: Awaited) => Promise | B, + h: (b: Awaited) => Promise | C, + i: (c: Awaited) => Promise | D, + j: (d: Awaited) => Promise | E, +): (...args: Args) => Promise>; + +export function pipeAsync( + f: AsyncFn, + g: (a: Awaited) => Promise | B, + h: (b: Awaited) => Promise | C, + i: (c: Awaited) => Promise | D, + j: (d: Awaited) => Promise | E, + k: (e: Awaited) => Promise | F, +): (...args: Args) => Promise>; + +export function pipeAsync( + f: AsyncFn, + g: (a: Awaited) => Promise | B, + h: (b: Awaited) => Promise | C, + i: (c: Awaited) => Promise | D, + j: (d: Awaited) => Promise | E, + k: (e: Awaited) => Promise | F, + l: (f_: Awaited) => Promise | G, +): (...args: Args) => Promise>; + +export function pipeAsync< + Args extends readonly unknown[], + A, + B, + C, + D, + E, + F, + G, + H, +>( + f: AsyncFn, + g: (a: Awaited) => Promise | B, + h: (b: Awaited) => Promise | C, + i: (c: Awaited) => Promise | D, + j: (d: Awaited) => Promise | E, + k: (e: Awaited) => Promise | F, + l: (f_: Awaited) => Promise | G, + m: (g_: Awaited) => Promise | H, +): (...args: Args) => Promise>; + +export function pipeAsync< + Args extends readonly unknown[], + A, + B, + C, + D, + E, + F, + G, + H, + I, +>( + f: AsyncFn, + g: (a: Awaited) => Promise | B, + h: (b: Awaited) => Promise | C, + i: (c: Awaited) => Promise | D, + j: (d: Awaited) => Promise | E, + k: (e: Awaited) => Promise | F, + l: (f_: Awaited) => Promise | G, + m: (g_: Awaited) => Promise | H, + n: (h_: Awaited) => Promise | I, +): (...args: Args) => Promise>; + +export function pipeAsync< + Args extends readonly unknown[], + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, +>( + f: AsyncFn, + g: (a: Awaited) => Promise | B, + h: (b: Awaited) => Promise | C, + i: (c: Awaited) => Promise | D, + j: (d: Awaited) => Promise | E, + k: (e: Awaited) => Promise | F, + l: (f_: Awaited) => Promise | G, + m: (g_: Awaited) => Promise | H, + n: (h_: Awaited) => Promise | I, + o: (i_: Awaited) => Promise | J, +): (...args: Args) => Promise>; + +/** + * Fallback overload for async pipes exceeding 10 functions. + * Type checking between intermediate functions is not enforced. */ -export const pipeAsync = ( - ...fns: PipeableAsync -): ((...args: PipeAsyncParams) => Promise>) => { +export function pipeAsync( + ...fns: UnaryAsyncFunction[] +): (...args: unknown[]) => Promise; + +export function pipeAsync( + ...fns: ((...args: unknown[]) => unknown)[] +): (...args: unknown[]) => Promise { const len = fns.length; - if (len > MAX_ASYNC_PIPE_DEPTH) { - throw new RangeError(`Async pipe depth exceeds ${MAX_ASYNC_PIPE_DEPTH}`); + if (len === 0) { + throw new Error('pipeAsync requires at least one function'); } - return async ( - ...args: PipeAsyncParams - ): Promise> => { - if (len === 1) { - // biome-ignore lint/suspicious/noExplicitAny: This is intended - return await (fns[0] as any)(...(args as any[])); - } + if (len === 1) { + // biome-ignore lint/style/noNonNullAssertion: Just checked if there is atleast one + return async (...args: unknown[]) => await fns[0]!(...args); + } - // Start with leftmost function (index: 0) - // biome-ignore lint/suspicious/noExplicitAny: This is intended - let result = await (fns[0] as any)(...(args as any[])); + return async (...args: unknown[]): Promise => { + // biome-ignore lint/style/noNonNullAssertion: I know there is at least two + let result = await fns[0]!(...args); - // Iterate forward from 1 to len - 1 for (let i = 1; i < len; i++) { - // biome-ignore lint/style/noNonNullAssertion: We know fns[i] exists because i is within [1, len-1] + // biome-ignore lint/style/noNonNullAssertion: Won't run more than it can result = await fns[i]!(result); } - return result as PipeAsyncReturn; + return result; }; -}; +} diff --git a/packages/composition/src/pipe-async/pipe-async.test.ts b/packages/composition/src/pipe-async/pipe-async.test.ts index 049727d..bb1a5aa 100644 --- a/packages/composition/src/pipe-async/pipe-async.test.ts +++ b/packages/composition/src/pipe-async/pipe-async.test.ts @@ -118,17 +118,6 @@ describe('PipeAsync', () => { await expect(piped(5)).rejects.toThrow('Sync error'); }); - - it('should throw on excessive pipe depth', () => { - const functions = Array(1001).fill(asyncAdd3); - - // @ts-expect-error For testing - expect(() => pipeAsync(...functions)).toThrow(RangeError); - // @ts-expect-error For testing - expect(() => pipeAsync(...functions)).toThrow( - 'Async pipe depth exceeds 1000', - ); - }); }); describe('Async Execution Order', () => { @@ -188,11 +177,8 @@ describe('PipeAsync', () => { const g = asyncMultiply2; const h = asyncSubtract1; - const fg: (x: number) => Promise = pipeAsync(f, g); - const gh: (x: number) => Promise = pipeAsync(g, h); - - const left = pipeAsync(fg, h); - const right = pipeAsync(f, gh); + const left = pipeAsync(pipeAsync(f, g), h); + const right = pipeAsync(f, pipeAsync(g, h)); const direct = pipeAsync(f, g, h); const testValue = 10; @@ -208,11 +194,8 @@ describe('PipeAsync', () => { const g = multiply2; // sync const h = asyncSubtract1; - const fg: (x: number) => Promise = pipeAsync(f, g); - const gh: (x: number) => Promise = pipeAsync(g, h); - - const left = pipeAsync(fg, h); - const right = pipeAsync(f, gh); + const left = pipeAsync(pipeAsync(f, g), h); + const right = pipeAsync(f, pipeAsync(g, h)); const testValue = 10; diff --git a/packages/composition/src/pipe/index.ts b/packages/composition/src/pipe/index.ts index 1607873..4f43adf 100644 --- a/packages/composition/src/pipe/index.ts +++ b/packages/composition/src/pipe/index.ts @@ -1,202 +1,179 @@ import type { UnaryFunction } from '../types'; /** - * Defines the valid shapes for function arrays that can be piped. + * Left-to-right variadic function composition (piping). * - * This union type represents two possible pipe compositions: - * 1. One n-ary function followed by zero or more unary functions - * 2. A single n-ary function + * Combines two or more functions to create a new function, passing the result + * from one function to the next until all have been called. The leftmost + * function is applied first to the input arguments. * - * The `readonly` modifier ensures immutability and enables better type inference - * with rest/spread patterns. The `...UnaryFunction[]` spread allows for any number - * of unary functions to follow the initial n-ary function. - */ -type PipeArray = - // biome-ignore lint/suspicious/noExplicitAny: This is intended - | readonly [(...args: any[]) => any, ...UnaryFunction[]] - // biome-ignore lint/suspicious/noExplicitAny: This is intended - | readonly [(...args: any[]) => any]; - -/** - * Extracts the parameter types of the leftmost (first) function in a pipe array. - * - * This type uses conditional type inference with `infer` to: - * 1. Pattern match against `readonly [infer First, ...unknown[]]` to capture the first element - * 2. Check if `First` is a function and extract its parameters with `infer P` - * 3. Return the parameter tuple type `P`, or `never` if extraction fails - * - * The `...unknown[]` spread matches any suffix elements without caring about their types, - * focusing only on the first element. This follows left-to-right pipe semantics - * where the leftmost function receives the initial arguments. + * @remarks + * Mathematical notation: `(f | g | h)(x) = h(g(f(x)))` * - * Example: - * PipeParams<[(a: boolean, b: string) => string, (x: string) => number]> - * // Result: [a: boolean, b: string] - */ -type PipeParams = Fns extends readonly [ - infer First, - ...unknown[], -] - ? // biome-ignore lint/suspicious/noExplicitAny: This is intended - First extends (...args: infer P) => any - ? P - : never - : never; - -/** - * Extracts the return type of the rightmost (last) function in a pipe array. + * Type constraints: + * - The leftmost (first) function can accept n parameters + * - All other functions must be unary (single parameter) + * - Return type of function `i` must be assignable to parameter of function `i+1` * - * This type mirrors PipeParams but focuses on the last element: - * 1. Pattern match against `readonly [...unknown[], infer Last]` to capture the last element - * 2. Check if `Last` is a function and extract its return type with `infer R` - * 3. Return the return type `R`, or `never` if extraction fails + * The overload-based signature provides reliable type inference up to 10 functions. + * For pipes exceeding 10 functions, nest multiple pipe calls. * - * The `...unknown[]` spread ignores all elements before the last. This follows - * pipe semantics where the rightmost function's return type becomes the - * overall pipe's return type. + * @example + * ```ts + * // Basic pipe + * const process = pipe( + * (x: number) => x + 1, + * (x: number) => x * 2, + * (x: number) => x.toString() + * ); + * process(4); // "10" * - * Example: - * PipeReturn<[(a: boolean) => string, (x: string) => number]> - * // Result: number + * // With n-ary leftmost function + * const sumAndFormat = pipe( + * (a: number, b: number) => a + b, + * (x: number) => x.toString(), + * (x: string) => `Result: ${x}` + * ); + * sumAndFormat(3, 4); // "Result: 7" + * ``` */ -type PipeReturn = Fns extends readonly [ - ...unknown[], - infer Last, -] - ? // biome-ignore lint/suspicious/noExplicitAny: This is intended - Last extends (...args: any[]) => infer R - ? R - : never - : never; +export function pipe( + f: (...args: Args) => A, +): (...args: Args) => A; -/** - * Validates and transforms a function array to ensure valid pipe structure. - * - * This recursive conditional type enforces that: - * 1. Base case: A single function (of any arity) is always valid - * 2. Recursive case: The first function can be n-ary, and the rest must be unary functions - * - * The constraint system works as follows: - * - `infer First extends (...args: any[]) => any` allows the first function to be n-ary - * - `infer Rest extends readonly UnaryFunction[]` ensures the remaining functions are unary - * - `readonly [First, ...Rest]` validates the structure - * - * This prevents invalid pipes like having non-unary functions in non-initial positions, - * which would break the pipe chain since intermediate functions can only receive one argument. - * - * Example transformations: - * Pipeable<[(x: number) => string]> - * // Result: [(x: number) => string] - * - * Pipeable<[(a: boolean, b: string) => string, (x: string) => number]> - * // Result: [(a: boolean, b: string) => string, (x: string) => number] - * - * Invalid example (would result in `never`): - * Pipeable<[(x: boolean) => string, (a: string, b: number) => boolean]> - * // Error: Second function is not unary - */ -type Pipeable = Fn extends readonly [ - // biome-ignore lint/suspicious/noExplicitAny: This is intended - (...args: any[]) => any, -] - ? Fn // Base case: single function (can be n-ary) - : Fn extends readonly [ - // biome-ignore lint/suspicious/noExplicitAny: This is intended - infer First extends (...args: any[]) => any, - ...infer Rest extends readonly UnaryFunction[], - ] - ? readonly [First, ...Rest] - : never; - -const MAX_PIPE_DEPTH = 1000; +export function pipe( + f: (...args: Args) => A, + g: (a: A) => B, +): (...args: Args) => B; + +export function pipe( + f: (...args: Args) => A, + g: (a: A) => B, + h: (b: B) => C, +): (...args: Args) => C; + +export function pipe( + f: (...args: Args) => A, + g: (a: A) => B, + h: (b: B) => C, + i: (c: C) => D, +): (...args: Args) => D; + +export function pipe( + f: (...args: Args) => A, + g: (a: A) => B, + h: (b: B) => C, + i: (c: C) => D, + j: (d: D) => E, +): (...args: Args) => E; + +export function pipe( + f: (...args: Args) => A, + g: (a: A) => B, + h: (b: B) => C, + i: (c: C) => D, + j: (d: D) => E, + k: (e: E) => F, +): (...args: Args) => F; + +export function pipe( + f: (...args: Args) => A, + g: (a: A) => B, + h: (b: B) => C, + i: (c: C) => D, + j: (d: D) => E, + k: (e: E) => F, + l: (f_: F) => G, +): (...args: Args) => G; + +export function pipe( + f: (...args: Args) => A, + g: (a: A) => B, + h: (b: B) => C, + i: (c: C) => D, + j: (d: D) => E, + k: (e: E) => F, + l: (f_: F) => G, + m: (g_: G) => H, +): (...args: Args) => H; + +export function pipe< + Args extends readonly unknown[], + A, + B, + C, + D, + E, + F, + G, + H, + I, +>( + f: (...args: Args) => A, + g: (a: A) => B, + h: (b: B) => C, + i: (c: C) => D, + j: (d: D) => E, + k: (e: E) => F, + l: (f_: F) => G, + m: (g_: G) => H, + n: (h_: H) => I, +): (...args: Args) => I; + +export function pipe< + Args extends readonly unknown[], + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, +>( + f: (...args: Args) => A, + g: (a: A) => B, + h: (b: B) => C, + i: (c: C) => D, + j: (d: D) => E, + k: (e: E) => F, + l: (f_: F) => G, + m: (g_: G) => H, + n: (h_: H) => I, + o: (i_: I) => J, +): (...args: Args) => J; /** - * Allows you combine two or more functions to create a new function, which passes the results from one - * function to the next until all have be called. Has a left-to-right call order. - * - * @template Fns - Tuple type of pipeable functions, where the first can be n-ary - * and all subsequent functions must be unary - * - * @param fns - The functions to pipe in left-to-right order - * - * @returns A new function accepting the parameters of the leftmost function in `fns`, - * returning the return type of the rightmost function in `fns` - * - * @remarks - * The implementation follows left-to-right pipe semantics: - * 1. The leftmost function is applied first to the input arguments - * 2. Each subsequent function (moving right) receives the result of the previous function - * 3. The rightmost function's result becomes the final output - * - * Performance: Maximum pipe depth is limited to 1000 functions to prevent stack overflow. - * - * @example - * const getActiveUsers = pipeVariadic( - * filterActive, - * sortUserNames, - * displayPage, - * ); - * - * const activeUsers = getActiveUsers(users, currentPage); + * Fallback overload for pipes exceeding 10 functions. + * Type checking between intermediate functions is not enforced. */ -export const pipeVariadic = ( - ...fns: Pipeable -): ((...args: PipeParams) => PipeReturn) => { +export function pipe(...fns: UnaryFunction[]): UnaryFunction; + +export function pipe( + ...fns: ((...args: unknown[]) => unknown)[] +): (...args: unknown[]) => unknown { const len = fns.length; - if (len > MAX_PIPE_DEPTH) { - throw new RangeError(`Pipe depth exceeds ${MAX_PIPE_DEPTH}`); + if (len === 0) { + throw new Error('pipe requires at least one function'); } - return (...args: PipeParams): PipeReturn => { - if (len === 1) { - // biome-ignore lint/suspicious/noExplicitAny: This is intended - return (fns[0] as any)(...(args as any[])) as PipeReturn; - } + if (len === 1) { + // biome-ignore lint/style/noNonNullAssertion: Just checked if there is atleast one + return fns[0]!; + } - // Start with leftmost function (index: 0) - // biome-ignore lint/suspicious/noExplicitAny: This is intended - let result = (fns[0] as any)(...(args as any[])); + return (...args: unknown[]): unknown => { + // biome-ignore lint/style/noNonNullAssertion: I know there is at least two + let result = fns[0]!(...args); - // Iterate forward from 1 to len - 1 for (let i = 1; i < len; i++) { - // biome-ignore lint/style/noNonNullAssertion: We know fns[i] exists because i is within [0, len-1] + // biome-ignore lint/style/noNonNullAssertion: Won't run more than it can result = fns[i]!(result); } - return result as PipeReturn; + return result; }; -}; - -/** - * @alias pipeVariadic - * - * Allows you combine two or more functions to create a new function, which passes the results from one - * function to the next until all have be called. Has a left-to-right call order. - * - * @template Fns - Tuple type of pipeable functions, where the first can be n-ary - * and all subsequent functions must be unary - * - * @param fns - The functions to pipe in left-to-right order - * - * @returns A new function accepting the parameters of the leftmost function in `fns`, - * returning the return type of the rightmost function in `fns` - * - * @remarks - * The implementation follows left-to-right pipe semantics: - * 1. The leftmost function is applied first to the input arguments - * 2. Each subsequent function (moving right) receives the result of the previous function - * 3. The rightmost function's result becomes the final output - * - * Performance: Maximum pipe depth is limited to 1000 functions to prevent stack overflow. - * - * @example - * const getActiveUsers = pipe( - * filterActive, - * sortUserNames, - * displayPage, - * ); - * - * const activeUsers = getActiveUsers(users, currentPage); - */ -export const pipe = pipeVariadic; +} diff --git a/packages/composition/vitest.config.js b/packages/composition/vitest.config.js index 9b624b7..d601790 100644 --- a/packages/composition/vitest.config.js +++ b/packages/composition/vitest.config.js @@ -16,5 +16,6 @@ export default defineConfig({ clearMocks: true, restoreMocks: true, passWithNoTests: true, + silent: 'passed-only', }, }); diff --git a/packages/lazy/vitest.config.js b/packages/lazy/vitest.config.js index 56cca42..38bf6bd 100644 --- a/packages/lazy/vitest.config.js +++ b/packages/lazy/vitest.config.js @@ -5,6 +5,10 @@ export default defineConfig({ plugins: [tsconfigPaths()], test: { watch: false, + benchmark: { + include: ['**/*.bench.ts'], + outputFile: './bench/report.json', + }, coverage: { provider: 'istanbul', reporter: ['json', 'json-summary', 'lcov', 'text'], @@ -16,9 +20,6 @@ export default defineConfig({ clearMocks: true, restoreMocks: true, passWithNoTests: true, - benchmark: { - include: ['**/*.bench.ts'], - outputFile: './bench/report.json', - }, + silent: 'passed-only', }, }); diff --git a/packages/list/vitest.config.js b/packages/list/vitest.config.js index 9b624b7..d601790 100644 --- a/packages/list/vitest.config.js +++ b/packages/list/vitest.config.js @@ -16,5 +16,6 @@ export default defineConfig({ clearMocks: true, restoreMocks: true, passWithNoTests: true, + silent: 'passed-only', }, }); diff --git a/packages/logic/vitest.config.js b/packages/logic/vitest.config.js index 9b624b7..d601790 100644 --- a/packages/logic/vitest.config.js +++ b/packages/logic/vitest.config.js @@ -16,5 +16,6 @@ export default defineConfig({ clearMocks: true, restoreMocks: true, passWithNoTests: true, + silent: 'passed-only', }, });