UNPKG

@doeixd/make-with

Version:

Lightweight function application utilities

1,428 lines (1,284 loc) 80 kB
/** * @file A functional utility library for creating powerful, immutable, and chainable APIs. * It provides tools for partial application and function composition, enabling elegant state * management patterns. * * @version 0.0.5 * @license MIT */ // A special symbol to identify objects of functions wrapped by `makeChainable`. const IS_CHAINABLE = Symbol("isChainable"); // Weak caches for performance optimization const API_CACHE = new WeakMap<object, WeakMap<object, any>>(); const VALIDATION_CACHE = new WeakSet<object>(); // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // // Type Definitions // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // /** A utility type representing a collection of methods for a given subject. */ type Methods<S extends object = object> = Record<string, MethodFunction<S>>; /** A function that operates on a subject and returns some result. */ type MethodFunction<S extends object> = (subject: S, ...args: readonly unknown[]) => unknown; /** * Helper type to detect if a method return type should be treated as chainable. * Chainable methods return exactly the subject type or an extension of it. */ type IsChainableReturn<R, S> = R extends S ? S extends R ? true // Exact match - definitely chainable : R extends S & infer _Ext ? true // Extended state - chainable : false // Subtype but not extension - not chainable : false; // No relation - not chainable /** * The return type of a `provideTo` (`makeWith`) call. It correctly infers the * return types for both regular methods and those marked as chainable. */ type ChainableApi<Fns extends Methods<S>, S extends object> = { [K in keyof Fns as K extends typeof IS_CHAINABLE ? never : K]: Fns[K] extends ( s: S, ...args: infer A ) => infer R ? IsChainableReturn<R, S> extends true ? (...args: A) => ChainableApi<Fns, S> : (...args: A) => R : never; }; /** A utility type that infers a simple, non-chainable API shape. Used by `makeLayered`. */ type BoundApi<S extends object, F extends Methods<S>> = { [K in keyof F]: F[K] extends (subject: S, ...args: infer A) => infer R ? (...args: A) => R : never; }; /** A function layer that receives the current API and returns methods to bind to it. */ type LayerFunction<CurrentApi extends object> = (currentApi: CurrentApi) => Methods<CurrentApi>; /** * Symbol to mark objects as composable, similar to IS_CHAINABLE. */ const IS_COMPOSABLE = Symbol("isComposable"); /** * Symbol to store the underlying state in API instances for composition. */ const INTERNAL_STATE = Symbol("internalState"); /** Enhanced LayeredApiBuilder type that supports both object and function layers. */ type LayeredApiBuilder<CurrentApi extends object> = { (): CurrentApi; <EnhancerFns extends Methods<CurrentApi>>( enhancerFns: EnhancerFns, ): LayeredApiBuilder<CurrentApi & BoundApi<CurrentApi, EnhancerFns>>; <LayerFn extends LayerFunction<CurrentApi>>( layerFn: LayerFn, ): LayeredApiBuilder<CurrentApi & BoundApi<CurrentApi, ReturnType<LayerFn>>>; }; // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // // Utility Functions // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // /** * Validates that all values in an object are functions. * Uses caching to avoid repeated validation of the same objects. */ function validateMethods(methods: Record<string, unknown>, context: string): void { if (VALIDATION_CACHE.has(methods)) { return; // Already validated } for (const [key, value] of Object.entries(methods)) { if (key === IS_CHAINABLE.toString()) continue; if (typeof value !== 'function') { throw new TypeError( `Invalid method "${key}" in ${context}: expected function, got ${typeof value}` ); } } VALIDATION_CACHE.add(methods); } /** * Type guard to check if a value is a LayerFunction. * LayerFunctions take exactly one parameter (the current API) and return methods. */ function isLayerFunction<T extends object>( value: unknown ): value is LayerFunction<T> { if (typeof value !== 'function') return false; // Check parameter count - LayerFunctions take exactly 1 parameter // Handle rest parameters (length = 0) by also checking for single-param signatures return value.length === 1 || (value.length === 0 && value.toString().includes('...')); } export class LayeredError extends Error { } /** * Creates a consistent error message format. */ function createError(context: string, message: string, cause?: unknown): Error { const error = new LayeredError(`[${context}] ${message}`, { cause }); return error; } /** * Creates a cached API instance to avoid recreating identical APIs. */ function getCachedApi<T>(subject: object, functionsMap: object, factory: () => T): T { // Atomic cache operations to prevent race conditions let subjectCache = API_CACHE.get(subject); if (!subjectCache) { const newCache = new WeakMap(); // Check again in case another thread created it subjectCache = API_CACHE.get(subject) || newCache; if (subjectCache === newCache) { API_CACHE.set(subject, subjectCache); } } let cachedApi = subjectCache.get(functionsMap); if (!cachedApi) { const newApi = factory(); // Check again in case another thread created it cachedApi = subjectCache.get(functionsMap) || newApi; if (cachedApi === newApi) { subjectCache.set(functionsMap, cachedApi); } } return cachedApi; } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // // Core Function Implementations // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // /** * Partially applies a value to a set of functions, returning new functions with the value pre-applied. * This is the simplest tool in the library, useful for reducing repetition when a group of * functions all need the same initial argument. * * @template S The type of the subject to be partially applied. * @param subject The value to partially apply to each function (e.g., a config object). * @returns A higher-order function that takes multiple functions and returns a new array of * functions, each with the `subject` pre-applied. * * @example * const config = { user: 'admin', retries: 3 }; * const [fetchData, deleteData] = _with(config)( * (cfg, path) => `Fetching ${path} for ${cfg.user}...`, * (cfg, id) => `Deleting ${id} with ${cfg.retries} retries...` * ); * fetchData('/items'); // "Fetching /items for admin..." */ export function _with<S>(subject: S) { if (subject === null || subject === undefined) { throw createError('_with', 'Subject cannot be null or undefined'); } return function <Fs extends ((subject: S, ...args: any[]) => any)[]>( ...fns: Fs ): { [K in keyof Fs]: Fs[K] extends (subject: S, ...args: infer A) => infer R ? (...args: A) => R : never; } { if (fns.length === 0) { throw createError('_with', 'At least one function must be provided'); } for (let i = 0; i < fns.length; i++) { if (typeof fns[i] !== 'function') { throw createError('_with', `Argument at index ${i} must be a function, got ${typeof fns[i]}`); } } return fns.map( (fn, index) => (...args: any[]) => { try { return fn(subject, ...args); } catch (error) { throw createError('_with', `Function at index ${index} threw an error`, error); } }, ) as any; }; } /** * Normalizes a collection of functions into a consistent key-value object. * It's a key utility for building APIs, as it accepts functions in two convenient formats: * an array of named functions, or a single object of functions. * * @param fnsOrObj Either an array of named functions or a single object. * @returns An object where keys are function names and values are the functions themselves. * @throws {Error} If inputs are invalid (non-functions, unnamed anonymous functions, or duplicate names). * * @example * // From named functions (name is used as key) * function greet(name) { return `Hello, ${name}`; } * const greeters = make(greet); // { greet: [Function: greet] } * * // From an object (keys are preserved) * const math = make({ add: (a, b) => a + b }); // { add: [Function: add] } */ export function make<F extends (...args: any[]) => any>( ...fns: F[] ): Record<string, F>; export function make<Obj extends Methods>(obj: Obj): Obj; export function make(...fnsOrObj: any[]): any { if (fnsOrObj.length === 0) { throw createError('make', 'At least one argument must be provided'); } if ( fnsOrObj.length === 1 && typeof fnsOrObj[0] === "object" && !Array.isArray(fnsOrObj[0]) && fnsOrObj[0] !== null ) { const functionsMap = fnsOrObj[0]; try { validateMethods(functionsMap, 'make'); return functionsMap; } catch (error) { throw createError('make', 'Object validation failed', error); } } const functionsMap: Record<string, (...args: any[]) => any> = {}; const seenNames = new Set<string>(); for (let i = 0; i < fnsOrObj.length; i++) { const fn = fnsOrObj[i]; if (typeof fn !== "function") { throw createError('make', `Argument at index ${i} must be a function, got ${typeof fn}`); } if (!fn.name || fn.name.trim() === '') { throw createError('make', `Function at index ${i} must have a non-empty name`); } if (seenNames.has(fn.name)) { throw createError('make', `Duplicate function name "${fn.name}" found`); } seenNames.add(fn.name); functionsMap[fn.name] = fn; } return functionsMap; } /** * Marks a set of functions as "chainable" for use with `provideTo` (`makeWith`). * A chainable function is a state updater that, when called, returns a new API instance * bound to the new state, enabling fluent method calls. * * @param fnsOrObj An object of state-updating functions, or an array of named state-updating functions. * @returns The same object of functions, but tagged with a special symbol for `provideTo` to recognize. * * @example * // From an object * const chainableMath = rebind({ * add: (state, n) => ({ ...state, value: state.value + n }), * multiply: (state, n) => ({ ...state, value: state.value * n }) * }); * * // From named functions * function increment(state) { return { ...state, count: state.count + 1 }; } * function decrement(state) { return { ...state, count: state.count - 1 }; } * const chainableCounters = rebind(increment, decrement); */ export function rebind<Obj extends Methods>(obj: Obj): Obj; export function rebind<Fs extends Array<(...args: any[]) => any>>( ...fns: Fs ): Record<string, Fs[number]>; export function rebind(...fnsOrObj: any[]): Record<string, any> { try { const originalFunctions = make(...fnsOrObj); (originalFunctions as any)[IS_CHAINABLE] = true; return originalFunctions; } catch (error) { throw createError('rebind', 'Failed to create chainable functions', error); } } /** * Creates a fluent API from a subject (state/config) and a collection of functions. * It partially applies the subject to each function. If the methods object was wrapped * with `makeChainable`, calling its methods will return a new, fully-formed API * bound to the resulting state. This is the core function for state management patterns. * * @template S The type of the subject (state). * @param subject The initial state to bind to the functions. * @returns A higher-order function that takes a map of methods and returns the final API. * * @example * const initialState = { count: 0, name: 'Counter' }; * * // Non-chainable methods (getters, utilities) * const getters = { * get: (state) => state.count, * getName: (state) => state.name * }; * * // Chainable methods (state updaters) * const updaters = makeChainable({ * increment: (state) => ({ ...state, count: state.count + 1 }), * setName: (state, name) => ({ ...state, name }) * }); * * const counterAPI = makeWith(initialState)(updaters); * const newAPI = counterAPI.increment().setName('Updated Counter'); * * const readOnlyAPI = makeWith(initialState)(getters); * const currentCount = readOnlyAPI.get(); // 0 */ export function makeWith<S extends object>(subject: S) { if (subject === null || subject === undefined) { throw createError('makeWith', 'Subject cannot be null or undefined'); } if (typeof subject !== 'object') { throw createError('makeWith', `Subject must be an object, got ${typeof subject}`); } return function <Fns extends Methods<S>>( functionsMap: Fns, ): ChainableApi<Fns, S> { if (!functionsMap || typeof functionsMap !== 'object') { throw createError('makeWith', 'Functions map must be a non-null object'); } // Use caching for identical subject + functionsMap combinations return getCachedApi(subject, functionsMap, () => { try { validateMethods(functionsMap, 'makeWith'); const finalApi: Record<string, unknown> = {}; const isChainable = (functionsMap as Record<string | symbol, unknown>)[IS_CHAINABLE]; const methodNames = Object.keys(functionsMap).filter(k => k !== IS_CHAINABLE.toString()); for (const key of methodNames) { const fn = functionsMap[key]; if (isChainable) { finalApi[key] = (...args: any[]) => { try { const newSubject = fn(subject, ...args); if (newSubject === undefined) { throw createError('makeWith', `Chainable method "${key}" returned undefined. Chainable methods must return a new state object.` ); } if (newSubject === null || (typeof newSubject !== 'object')) { throw createError('makeWith', `Chainable method "${key}" returned ${newSubject === null ? 'null' : typeof newSubject}. Chainable methods must return a new state object.` ); } return makeWith(newSubject)(functionsMap as unknown as Methods<typeof newSubject>); } catch (error) { throw createError('makeWith', `Chainable method "${key}" failed`, error); } }; } else { finalApi[key] = (...args: any[]) => { try { return fn(subject, ...args); } catch (error) { throw createError('makeWith', `Method "${key}" failed`, error); } }; } } // Store the state in the API instance for composition access (finalApi as Record<string | symbol, unknown>)[INTERNAL_STATE] = subject; return finalApi as ChainableApi<Fns, S>; } catch (error) { throw createError('makeWith', 'API creation failed', error); } }); }; } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // // Composition Primitives // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // /** * Creates a composable object where methods can access previous methods with the same name. * Automatically handles both regular and chainable methods - the previous method always * returns the appropriate result (state for chainable, actual result for regular). * * @template T The type of the composable methods object. * @param methods An object where each method receives the previous method as its last parameter. * @returns A marked object that can be used in `makeLayered` for composition. * * @example * // Composing regular methods * const api = makeLayered({ count: 0 }) * ({ * get: (s) => s.count, * increment: (s) => ({ count: s.count + 1 }) * }) * (compose({ * get: (s, prevGet) => { * const value = prevGet(s); // Returns number * console.log('Current count:', value); * return value; * } * })) * (); * * @example * // Composing chainable methods (automatically handled) * const api = makeLayered({ count: 0 }) * (makeChainable({ * increment: (s) => ({ count: s.count + 1 }), * add: (s, amount) => ({ count: s.count + amount }) * })) * (compose({ * increment: (s, prevIncrement) => { * console.log('Before increment:', s.count); * const newState = prevIncrement(s); // Always returns state object * console.log('After increment:', newState.count); * return newState; // Returns state, becomes chainable automatically * }, * add: (s, amount, prevAdd) => { * if (amount < 0) { * console.warn('Adding negative amount:', amount); * amount = Math.abs(amount); * } * return prevAdd(s, amount); // Returns state object * } * })) * (); * * @example * // Mixed composition - some methods chainable, some not * const userAPI = makeLayered({ users: [], count: 0 }) * (makeChainable({ * addUser: (s, user) => ({ ...s, users: [...s.users, user] }) * })) * ({ * getUserCount: (s) => s.users.length, * validateUser: (s, user) => user.name && user.email * }) * (compose({ * addUser: (s, user, prevAdd) => { * if (!s.validateUser(user)) throw new Error('Invalid user'); * return prevAdd(s, { ...user, id: crypto.randomUUID() }); // Returns state * }, * getUserCount: (s, prevGetCount) => { * const count = prevGetCount(s); // Returns number * console.log(`Total users: ${count}`); * return count; * } * })) * (); */ export function compose<T extends Record<string, MethodFunction<any>>>( methods: T ): T & { [IS_COMPOSABLE]: true } { if (!methods || typeof methods !== 'object') { throw createError('compose', 'Methods must be a non-null object'); } // Validate that all properties are functions for (const [key, value] of Object.entries(methods)) { if (typeof value !== 'function') { throw createError('compose', `Method "${key}" must be a function, got ${typeof value}`); } } // Mark the object as composable return Object.assign(methods, { [IS_COMPOSABLE]: true as const }); } /** * Merges multiple method objects with later objects taking precedence, or creates a curried * merger function for partial application. This enhanced version supports both immediate merging * and functional composition patterns. * * @template T The type of method objects to merge. * @param objects Method objects to merge, in order of precedence (later objects override earlier). * @returns A single merged object, or a curried function for further merging. * * @example * // Direct merging (original behavior) * const baseMethods = { get: (s) => s.value, set: (s, v) => ({ value: v }) }; * const extensions = { increment: (s) => ({ value: s.value + 1 }) }; * const validation = { set: (s, v) => v >= 0 ? ({ value: v }) : s }; // Override set * * const allMethods = merge(baseMethods, extensions, validation); * const api = makeWith({ value: 0 })(allMethods); * * @example * // Curried usage for extension patterns * const addDefaults = merge({ role: 'user', active: true }); * const withAuth = merge({ isAuthenticated: (s) => !!s.token }); * * const userMethods = addDefaults(withAuth({ login: (s, token) => ({ ...s, token }) })); * * @example * // Building reusable extensions * const withTimestamp = merge({ * addTimestamp: (s) => ({ ...s, createdAt: Date.now() }), * updateTimestamp: (s) => ({ ...s, updatedAt: Date.now() }) * }); * * const withValidation = merge({ * validate: (s, rules) => rules.every(rule => rule(s)) * }); * * // Compose multiple extensions * const enhancedAPI = makeWith(initialState)( * withValidation(withTimestamp(baseMethods)) * ); * * @example * // Conditional merging with currying * const createUserAPI = (isAdmin: boolean) => { * const base = { getProfile: (s) => s.profile }; * const adminMethods = isAdmin ? { deleteUser: (s, id) => ({ ...s, deleted: [...s.deleted, id] }) } : {}; * return makeWith(initialState)(merge(base)(adminMethods)); * }; * * @example * // Chaining extensions functionally * const processUser = (baseUser) => * merge({ id: crypto.randomUUID() })( * merge({ createdAt: Date.now() })( * merge({ role: 'user' })(baseUser) * ) * ); */ export function merge<T extends Methods>(...objects: T[]): T; export function merge<T extends Methods>( firstObject: T ): <U extends Methods>(...additionalObjects: U[]) => T & U; export function merge<T extends Methods>(...objects: T[]): T | (<U extends Methods>(...additionalObjects: U[]) => T & U) { if (objects.length === 0) { throw createError('merge', 'At least one object must be provided'); } // Validate the first object const firstObj = objects[0]; if (!firstObj || typeof firstObj !== 'object') { throw createError('merge', 'First argument must be a non-null object'); } try { validateMethods(firstObj, 'merge first argument'); } catch (error) { throw createError('merge', 'First object validation failed', error); } // If only one object provided, return a curried function if (objects.length === 1) { return function <U extends Methods>(...additionalObjects: U[]): T & U { if (additionalObjects.length === 0) { throw createError('merge', 'At least one additional object must be provided to merge'); } // Validate additional objects for (let i = 0; i < additionalObjects.length; i++) { const obj = additionalObjects[i]; if (!obj || typeof obj !== 'object') { throw createError('merge', `Additional argument at index ${i} must be a non-null object`); } try { validateMethods(obj, `merge additional argument ${i}`); } catch (error) { throw createError('merge', `Additional object at index ${i} validation failed`, error); } } // Merge first object with additional objects return Object.assign({}, firstObj, ...additionalObjects) as T & U; }; } // Multiple objects provided - merge immediately (original behavior) for (let i = 1; i < objects.length; i++) { const obj = objects[i]; if (!obj || typeof obj !== 'object') { throw createError('merge', `Argument at index ${i} must be a non-null object`); } try { validateMethods(obj, `merge argument ${i}`); } catch (error) { throw createError('merge', `Object at index ${i} validation failed`, error); } } return Object.assign({}, ...objects); } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // // Advanced Merge Primitives // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // /** Type representing a property descriptor that can be used as a merge key */ type MergePropertyDescriptor = { key: string | symbol; enumerable?: boolean; configurable?: boolean; writable?: boolean; }; /** Type for merge result - either success with merged object or failure with error details */ type MergeResult<T> = | { success: true; data: T } | { success: false; failures: Array<[string | symbol, unknown]> }; /** Type for merge definition functions */ type MergeDefinition<T> = { [K in keyof T]?: (objA: Pick<T, K>, objB: Pick<T, K>, key: K) => T[K] | { error: unknown }; }; /** Type for tuple-based merge definitions */ type TupleMergeDefinition<T extends Record<string | symbol, any>> = Array<[ keyof T | MergePropertyDescriptor, (objA: any, objB: any, key: keyof T) => T[keyof T] | { error: unknown } ]>; /** Helper to get property descriptors for objects */ export function getKeyDescriptions<T extends object, U extends object>( objA: T, objB: U ): Array<[string | symbol, PropertyDescriptor, PropertyDescriptor]> { const allKeys = new Set([ ...Object.getOwnPropertyNames(objA), ...Object.getOwnPropertySymbols(objA), ...Object.getOwnPropertyNames(objB), ...Object.getOwnPropertySymbols(objB) ]); return Array.from(allKeys).map(key => { const descA = Object.getOwnPropertyDescriptor(objA, key) || { enumerable: false, configurable: false, writable: false, key }; const descB = Object.getOwnPropertyDescriptor(objB, key) || { enumerable: false, configurable: false, writable: false, key }; return [key, descA, descB] as [string | symbol, PropertyDescriptor, PropertyDescriptor]; }); } /** * Creates a type-safe, auto-curried merger function that can merge objects according to custom merge strategies. * Supports both object-based and tuple-based merge definitions with comprehensive error handling. * * @template T The type of objects to be merged. * @param mergeDefinition An object where each key maps to a function that defines how to merge that property. * @returns An auto-curried function that merges objects or returns detailed failure information. * * @example * // Basic usage with merge definition object * interface User { * name: string; * age: number; * tags: string[]; * } * * const userMerger = createMerger<User>({ * name: (a, b, key) => a.name || b.name, * age: (a, b, key) => Math.max(a.age || 0, b.age || 0), * tags: (a, b, key) => [...(a.tags || []), ...(b.tags || [])] * }); * * const user1 = { name: "Alice", age: 25, tags: ["admin"] }; * const user2 = { name: "", age: 30, tags: ["user"] }; * * const result = userMerger(user1, user2); * if (result.success) { * console.log(result.data); // { name: "Alice", age: 30, tags: ["admin", "user"] } * } * * @example * // Auto-currying support * const partialMerger = userMerger(user1); * const result2 = partialMerger(user2); // Same result as above * * @example * // Error handling * const strictMerger = createMerger<User>({ * name: (a, b, key) => a.name === b.name ? a.name : { error: "Name mismatch" }, * age: (a, b, key) => a.age > 0 && b.age > 0 ? Math.max(a.age, b.age) : { error: "Invalid age" } * }); * * const badResult = strictMerger({ name: "Alice", age: -1, tags: [] }, { name: "Bob", age: 30, tags: [] }); * if (!badResult.success) { * console.log(badResult.failures); // [["name", "Name mismatch"], ["age", "Invalid age"]] * } */ export function createMerger<T extends Record<string | symbol, any>>( mergeDefinition: MergeDefinition<T> ): { (objA: Partial<T>): (objB: Partial<T>) => MergeResult<T>; (objA: Partial<T>, objB: Partial<T>): MergeResult<T>; }; /** * Creates a merger using tuple-based definitions for advanced property descriptor handling. * * @template T The type of objects to be merged. * @param tupleDefinitions Array of tuples where each tuple contains a key/descriptor and merge function. * @returns An auto-curried function that merges objects according to the tuple definitions. * * @example * // Using property descriptors * const descriptorMerger = createMerger<{ value: number; meta: string }>([ * ["value", (a, b, key) => (a.value || 0) + (b.value || 0)], * [{ key: "meta", enumerable: true }, (a, b, key) => `${a.meta || ""}_${b.meta || ""}`] * ]); * * const obj1 = { value: 10, meta: "first" }; * const obj2 = { value: 20, meta: "second" }; * const result = descriptorMerger(obj1, obj2); * // result.data = { value: 30, meta: "first_second" } */ export function createMerger<T extends Record<string | symbol, any>>( tupleDefinitions: TupleMergeDefinition<T> ): { (objA: Partial<T>): (objB: Partial<T>) => MergeResult<T>; (objA: Partial<T>, objB: Partial<T>): MergeResult<T>; }; export function createMerger<T extends Record<string | symbol, any>>( definition: MergeDefinition<T> | TupleMergeDefinition<T> ): { (objA: Partial<T>): (objB: Partial<T>) => MergeResult<T>; (objA: Partial<T>, objB: Partial<T>): MergeResult<T>; } { if (!definition) { throw createError('createMerger', 'Merge definition cannot be null or undefined'); } // Normalize tuple definitions to object format let normalizedDefinition: MergeDefinition<T>; if (Array.isArray(definition)) { normalizedDefinition = {} as MergeDefinition<T>; for (const [keyOrDescriptor, mergeFn] of definition) { if (typeof keyOrDescriptor === 'object' && 'key' in keyOrDescriptor) { const key = keyOrDescriptor.key as keyof T; normalizedDefinition[key] = mergeFn as any; } else { const key = keyOrDescriptor as keyof T; normalizedDefinition[key] = mergeFn as any; } } } else { normalizedDefinition = definition; } // Validate the definition for (const [key, mergeFn] of Object.entries(normalizedDefinition)) { if (typeof mergeFn !== 'function') { throw createError('createMerger', `Merge function for key "${key}" must be a function, got ${typeof mergeFn}`); } } function performMerge(objA: Partial<T>, objB: Partial<T>): MergeResult<T> { if (!objA || typeof objA !== 'object') { throw createError('createMerger', 'First object must be a non-null object'); } if (!objB || typeof objB !== 'object') { throw createError('createMerger', 'Second object must be a non-null object'); } const result = {} as T; const failures: Array<[string | symbol, unknown]> = []; // Get all keys from both objects const allKeys = new Set([ ...Object.getOwnPropertyNames(objA), ...Object.getOwnPropertySymbols(objA), ...Object.getOwnPropertyNames(objB), ...Object.getOwnPropertySymbols(objB) ]); for (const key of allKeys) { const typedKey = key as keyof T; const mergeFn = normalizedDefinition[typedKey]; if (mergeFn) { try { const objAWithKey = objA.hasOwnProperty(key) ? ({ [key]: objA[typedKey] } as unknown as Pick<T, typeof typedKey>) : ({} as Pick<T, typeof typedKey>); const objBWithKey = objB.hasOwnProperty(key) ? ({ [key]: objB[typedKey] } as unknown as Pick<T, typeof typedKey>) : ({} as Pick<T, typeof typedKey>); const mergeResult = mergeFn(objAWithKey, objBWithKey, typedKey); if (mergeResult && typeof mergeResult === 'object' && 'error' in mergeResult) { failures.push([key, mergeResult.error]); } else { result[typedKey] = mergeResult; } } catch (error) { failures.push([key, error]); } } else { // Default behavior: objB takes precedence if (objB.hasOwnProperty(key)) { result[typedKey] = objB[typedKey]!; } else if (objA.hasOwnProperty(key)) { result[typedKey] = objA[typedKey]!; } } } if (failures.length > 0) { return { success: false, failures }; } return { success: true, data: result }; } // Auto-curried implementation function merger(objA: Partial<T>): (objB: Partial<T>) => MergeResult<T>; function merger(objA: Partial<T>, objB: Partial<T>): MergeResult<T>; function merger(objA: Partial<T>, objB?: Partial<T>): any { if (objB === undefined) { // Return curried function return (secondObj: Partial<T>) => performMerge(objA, secondObj); } // Perform immediate merge return performMerge(objA, objB); } return merger; } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // // Dynamic API Generation Primitives // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // /** Type for proxy handler function result */ type ProxyHandlerResult<T> = T | { error: unknown } | undefined; /** Type for proxy handler function */ type ProxyHandler<S extends Record<string | symbol, any>> = ( state: S, methodName: string | symbol, ...args: unknown[] ) => ProxyHandlerResult<any>; /** Type for proxy handler utility function */ type ProxyHandlerUtil<S extends Record<string | symbol, any>> = ( state: S, methodName: string | symbol, ...args: unknown[] ) => ProxyHandlerResult<any>; /** Lens getter function type */ type LensGetter<S, T> = (state: S) => T; /** Lens setter function type */ type LensSetter<S, T> = (state: S, focused: T) => S; /** * Utility function for common get/set pattern in createProxy. * Handles getXxx() and setXxx() method patterns automatically. * * @template S The type of the state object. * @param state The current state. * @param methodName The method being called. * @param args The arguments passed to the method. * @returns The result of the get/set operation or undefined if pattern doesn't match. * * @example * const userAPI = createProxy(getSet); * // Automatically provides: getName(), setName(value), getAge(), setAge(value), etc. */ export function getSet<S extends Record<string | symbol, any>>( state: S, methodName: string | symbol, ...args: unknown[] ): ProxyHandlerResult<any> { const method = String(methodName); if (method.startsWith('get') && method.length > 3) { const field = method.charAt(3).toLowerCase() + method.slice(4); return state[field as keyof S]; } if (method.startsWith('set') && method.length > 3 && args.length > 0) { const field = method.charAt(3).toLowerCase() + method.slice(4); if (field in state) { return { ...state, [field]: args[0] } as S; } } return undefined; } /** * Utility function that wraps another proxy handler to ignore case in method names. * * @template S The type of the state object. * @param handler The proxy handler to wrap. * @returns A new handler that normalizes method names to lowercase. * * @example * const userAPI = createProxy(ignoreCase(getSet)); * // Now getName(), getname(), GETNAME() all work the same way */ export function ignoreCase<S extends Record<string | symbol, any>>( handler: ProxyHandlerUtil<S> ): ProxyHandlerUtil<S> { return (state: S, methodName: string | symbol, ...args: unknown[]) => { const normalizedName = typeof methodName === 'string' ? methodName.toLowerCase() : methodName; return handler(state, normalizedName, ...args); }; } /** * Utility function that wraps another proxy handler to strip special characters from method names. * * @template S The type of the state object. * @param handler The proxy handler to wrap. * @returns A new handler that removes special characters from method names. * * @example * const userAPI = createProxy(noSpecialChars(getSet)); * // Now get_name(), get-name(), get$name() all become getname() */ export function noSpecialChars<S extends Record<string | symbol, any>>( handler: ProxyHandlerUtil<S> ): ProxyHandlerUtil<S> { return (state: S, methodName: string | symbol, ...args: unknown[]) => { const cleanName = typeof methodName === 'string' ? methodName.replace(/[^a-zA-Z0-9]/g, '') : methodName; return handler(state, cleanName, ...args); }; } /** * Utility function that combines multiple proxy handlers, trying each in order until one returns a result. * * @template S The type of the state object. * @param handlers Array of proxy handlers to try in order. * @returns A combined handler that delegates to the first matching handler. * * @example * const userAPI = createProxy(fallback([ * customMethods, * getSet, * (state, method) => `Method ${method} not found` * ])); */ export function fallback<S extends Record<string | symbol, any>>( handlers: ProxyHandlerUtil<S>[] ): ProxyHandlerUtil<S> { return (state: S, methodName: string | symbol, ...args: unknown[]) => { for (const handler of handlers) { const result = handler(state, methodName, ...args); if (result !== undefined) { return result; } } return undefined; }; } /** * Creates a dynamic API using ES6 Proxy that generates methods on-the-fly based on a handler function. * This enables highly flexible APIs where methods are created dynamically based on state shape or naming patterns. * * @template S The type of the state object. * @param handler Function that receives (state, methodName, ...args) and returns the result or new state. * @returns A function that takes initial state and returns a dynamic API with auto-generated methods. * * @example * // Basic usage with built-in getSet utility * interface User { * name: string; * age: number; * email: string; * } * * const userAPI = createProxy<User>(getSet)({ * name: "Alice", * age: 25, * email: "alice@example.com" * }); * * // Methods are generated automatically: * const name = userAPI.getName(); // "Alice" * const updated = userAPI.setAge(26); // Returns new API with age: 26 * const email = updated.getEmail(); // "alice@example.com" * * @example * // Composing utilities for flexible method generation * const flexibleAPI = createProxy<User>( * ignoreCase(noSpecialChars(getSet)) * )({ name: "Bob", age: 30, email: "bob@test.com" }); * * // All these work the same way: * flexibleAPI.getName(); // Standard * flexibleAPI.getname(); // Ignore case * flexibleAPI.get_name(); // No special chars * flexibleAPI.GET_NAME(); // Both combined * * @example * // Custom handler with business logic * interface Counter { * count: number; * step: number; * } * * const counterAPI = createProxy<Counter>((state, method, ...args) => { * const methodStr = String(method); * * if (methodStr === 'increment') { * return { ...state, count: state.count + state.step }; * } * * if (methodStr === 'decrement') { * return { ...state, count: state.count - state.step }; * } * * if (methodStr === 'reset') { * return { ...state, count: 0 }; * } * * if (methodStr.startsWith('add')) { * const amount = args[0] as number; * return { ...state, count: state.count + amount }; * } * * // Fallback to getSet for other methods * return getSet(state, method, ...args); * })({ count: 0, step: 1 }); * * const result = counterAPI * .increment() // count: 1 * .increment() // count: 2 * .add(5) // count: 7 * .setStep(2) // step: 2 * .increment(); // count: 9 * * @example * // Error handling with validation * const validatedAPI = createProxy<User>((state, method, ...args) => { * const result = getSet(state, method, ...args); * * // Add validation for setters * if (String(method).startsWith('set') && result && typeof result === 'object') { * if ('age' in result && (result as any).age < 0) { * return { error: 'Age cannot be negative' }; * } * if ('email' in result && !(result as any).email.includes('@')) { * return { error: 'Invalid email format' }; * } * } * * return result; * })({ name: "Alice", age: 25, email: "alice@example.com" }); * * const badResult = validatedAPI.setAge(-5); // Returns { error: 'Age cannot be negative' } * const goodResult = validatedAPI.setAge(26); // Returns new API with age: 26 */ export function createProxy<S extends Record<string | symbol, any>>( handler: ProxyHandler<S> ): (initialState: S) => any { if (typeof handler !== 'function') { throw createError('createProxy', 'Handler must be a function'); } return function (initialState: S) { if (!initialState || typeof initialState !== 'object') { throw createError('createProxy', 'Initial state must be a non-null object'); } // Create the base API object with internal state const baseAPI = { [INTERNAL_STATE]: initialState, }; // Create and return the proxy return new Proxy(baseAPI, { get(target: any, prop: string | symbol): any { // Return internal state if requested if (prop === INTERNAL_STATE) { return target[INTERNAL_STATE]; } // Skip symbol properties and prototype methods if (typeof prop === 'symbol' || prop === 'constructor' || prop === 'toString' || prop === 'valueOf') { return target[prop]; } // Return a function that calls the handler return function (...args: unknown[]) { try { const currentState = target[INTERNAL_STATE]; const result = handler(currentState, prop, ...args); // Handle error results if (result && typeof result === 'object' && 'error' in result) { throw createError('createProxy', `Method "${String(prop)}" failed: ${result.error}`); } // If result is undefined, the method doesn't exist if (result === undefined) { throw createError('createProxy', `Method "${String(prop)}" is not supported`); } // If result is the same type as state, treat as chainable (return new proxy) if (result && typeof result === 'object' && typeof currentState === 'object') { // Check if this looks like a state update (has similar structure) const stateKeys = Object.keys(currentState); const resultKeys = Object.keys(result); const isStateUpdate = stateKeys.some(key => key in result) || resultKeys.length > 0; if (isStateUpdate) { // Return new proxy with updated state return createProxy(handler)(result as S); } } // Otherwise return the result directly (non-chainable) return result; } catch (error) { if (error instanceof LayeredError) { throw error; } throw createError('createProxy', `Method "${String(prop)}" execution failed`, error); } }; }, has(_target: any, prop: string | symbol): boolean { // Internal symbols always exist if (prop === INTERNAL_STATE) return true; // For dynamic methods, we can't know ahead of time, so return true for string props return typeof prop === 'string'; }, ownKeys(target: any): ArrayLike<string | symbol> { // Return internal state keys plus common method patterns const state = target[INTERNAL_STATE]; const stateKeys = Object.keys(state); // Generate common getter/setter patterns const methodNames = stateKeys.flatMap(key => [ `get${key.charAt(0).toUpperCase() + key.slice(1)}`, `set${key.charAt(0).toUpperCase() + key.slice(1)}` ]); return [...methodNames, INTERNAL_STATE]; }, getOwnPropertyDescriptor(_target: any, prop: string | symbol) { if (prop === INTERNAL_STATE) { return { configurable: true, enumerable: false, writable: true }; } // Dynamic methods are configurable and enumerable if (typeof prop === 'string') { return { configurable: true, enumerable: true, writable: false }; } return undefined; } }); }; } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // // State Focus Primitives // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // /** * Creates a lens that focuses method operations on a specific slice of state. * This enables building APIs that operate on nested state structures while maintaining * type safety and immutability patterns. * * @template S The type of the full state object. * @template T The type of the focused state slice. * @param getter Function that extracts the focused slice from the full state. * @param setter Function that updates the full state with a new focused slice. * @returns A function that takes methods and returns focused versions of those methods. * * @example * // Basic lens usage for nested state * interface AppState { * user: { * name: string; * email: string; * preferences: { * theme: string; * notifications: boolean; * }; * }; * posts: Post[]; * ui: UIState; * } * * // Create a lens focused on the user slice * const userLens = createLens<AppState, AppState['user']>( * state => state.user, * (state, user) => ({ ...state, user }) * ); * * // Methods that operate on the user slice * const userMethods = { * updateName: (user, name: string) => ({ ...user, name }), * updateEmail: (user, email: string) => ({ ...user, email }), * getName: (user) => user.name, * getEmail: (user) => user.email * }; * * // Create API focused on user slice * const appAPI = makeWith(appState)(userLens(userMethods)); * * // Operations automatically work on the user slice * const newAppState = appAPI.updateName("Alice").updateEmail("alice@example.com"); * // Full app state is updated, but methods only see/modify user slice * * @example * // Deeply nested lens with preferences * const preferencesLens = createLens<AppState, AppState['user']['preferences']>( * state => state.user.preferences, * (state, prefs) => ({ * ...state, * user: { ...state.user, preferences: prefs } * }) * ); * * const prefMethods = { * setTheme: (prefs, theme: string) => ({ ...prefs, theme }), * toggleNotifications: (prefs) => ({ ...prefs, notifications: !prefs.notifications }), * getTheme: (prefs) => prefs.theme * }; * * const prefsAPI = makeWith(appState)(preferencesLens(prefMethods)); * const updated = prefsAPI.setTheme("dark").toggleNotifications(); * * @example * // Chainable lens with makeChainable * const chainableUserLens = createLens<AppState, AppState['user']>( * state => state.user, * (state, user) => ({ ...state, user }) * ); * * const chainableAPI = makeWith(appState)( * chainableUserLens(makeChainable({ * setName: (user, name: string) => ({ ...user, name }), * setEmail: (user, email: string) => ({ ...user, email }), * clearEmail: (user) => ({ ...user, email: "" }) * })) * ); * * // Fluent chainable API that focuses on user slice * const result = chainableAPI * .setName("Bob") * .setEmail("bob@example.com") * .clearEmail(); * * @example * // Lens composition for complex state management * interface BlogState { * posts: { id: string; title: string; content: string; author: string }[]; * authors: { id: string; name: string; email: string }[]; * currentPost: string | null; * } * * // Lens for posts array * const postsLens = createLens<BlogState, BlogState['posts']>( * state => state.posts, * (state, posts) => ({ ...state, posts }) * ); * * // Lens for current post (returns single post or null) * const currentPostLens = createLens<BlogState, BlogState['posts'][0] | null>( * state => state.currentPost ? state.posts.find(p => p.id === state.currentPost) || null : null, * (state, post) => post ? { * ...state, * posts: state.posts.map(p => p.id === post.id ? post : p) * } : state * ); * * const blogAPI = makeLayered(blogState) * (postsLens({ * addPost: (posts, post) => [...posts, { ...post, id: crypto.randomUUID() }], * removePost: (posts, id: string) => posts.filter(p => p.id !== id) * })) * (currentPostLens({ * updateTitle: (post, title: string) => post ? { ...post, title } : null, * updateContent: (post, content: string) => post ? { ...post, content } : null * })) * (); * * @example * // Array lens for working with specific array elements * const createArrayLens = <S, T>( * getter: (state: S) => T[], * setter: (state: S, array: T[]) => S, * index: number * ) => createLens<S, T | undefined>( * state => getter(state)[index], * (state, item) => { * const array = getter(state); * if (item === undefined) return setter(state, array.filter((_, i) => i !== index)); * const newArray = [...array]; * newArray[index] = item; * return setter(state, newArray); * } * ); * * // Focus on first post * const firstPostLens = createArrayLens( * (state: BlogState) => state.posts, * (state, posts) => ({ ...state, posts }), * 0 * ); * * const firstPostAPI = makeWith(blogState)(firstPostLens({ * setTitle: (post, title: string) => post ? { ...post, title } : undefined, * getTitle: (post) => post?.title || 'No post' * })); */ export function createLens<S extends object, T>( getter: LensGetter<S, T>, setter: LensSetter<S, T> ): <M extends Record<string, (focused: T, ...args: any[]) => any>>(methods: M) => Methods<S> { if (typeof getter !== 'function') { throw createError('createLens', 'Getter must be a function'); } if (typeof setter !== 'function') { throw createError('createLens', 'Setter must be a function'); } return function <M extends Record<string, (focused: T, ...args: any[]) => any>>(methods: M): Methods<S> { if (!methods || typeof methods !== 'object') { throw createError('createLens', 'Methods must be a non-null object'); } // Validate methods try { validateMethods(methods, 'createLens'); } catch (error) { throw createError('createLens', 'Methods validation failed', error); } const focusedMethods: Methods<S> = {}; const isChainable = (methods as Record<string | symbol, unknown>)[IS_CHAINABLE] === true; // Preserve chainable marker if present if (isChainable) { (focusedMethods as any)[IS_CHAINABLE] = true; } for (const [methodName, method] of Object.entries(methods)) { if (methodName === IS_CHAINABLE.toString()) continue; if (typeof method !== 'function') { throw createError('createLens', `Met