@doeixd/make-with
Version:
Lightweight function application utilities
1,428 lines (1,284 loc) • 80 kB
text/typescript
/**
* @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