How to Type Lodash memoize with TypeScript Generics
Using Lodash’s _.memoize with complex generic functions
often results in lost type inference, where TypeScript collapses generic
type parameters to their constraints or to unknown. This
article explains the limitation of the standard
@types/lodash declarations when dealing with higher-rank
generic functions and provides concrete solutions, including custom
higher-order wrappers and type assertion helpers, to strictly preserve
generic parameters, return types, and cache resolver arguments.
The Inference Problem with
_.memoize
The type definition for _.memoize in
@types/lodash generally looks like this:
memoize<T extends (...args: any[]) => any>(
func: T,
resolver?: (...args: Parameters<T>) => any
): T & _.MemoizedFunction;When you pass a generic function (a function template like
<T>(arg: T) => T) into _.memoize,
TypeScript cannot return an uninstantiated generic function. Instead, it
instantiates the type parameters at the call site against their
constraints (often unknown or {}).
Consequently, the returned memoized function loses its ability to accept
arbitrary generic arguments dynamically.
Solution 1: Creating a Generic-Preserving Wrapper Function
Because TypeScript cannot dynamically infer higher-rank generic
signatures passed into standard function parameters, the cleanest
approach is to define a typed wrapper around _.memoize.
This wrapper preserves the generic signature by using explicit generic
parameters.
Consider a complex generic function that extracts nested properties based on key paths:
import _ from 'lodash';
// Example complex generic function
function fetchNestedProperty<T, K extends keyof T, SubK extends keyof T[K]>(
data: T,
primaryKey: K,
secondaryKey: SubK
): T[K][SubK] {
return data[primaryKey][secondaryKey];
}To memoize this without losing type safety, define a strongly-typed wrapper:
function memoizeNestedFetcher<
T,
K extends keyof T,
SubK extends keyof T[K]
>(
fn: (data: T, primaryKey: K, secondaryKey: SubK) => T[K][SubK],
resolver?: (data: T, primaryKey: K, secondaryKey: SubK) => string
) {
return _.memoize(fn, resolver) as typeof fn & _.MemoizedFunction;
}
// Usage
const memoizedFetch = memoizeNestedFetcher(
fetchNestedProperty,
(data, primaryKey, secondaryKey) => `${String(primaryKey)}_${String(secondaryKey)}`
);Solution 2: Preserving Generic Higher-Rank Types via Explicit Casting
When dealing with functions that must remain generic for different
callers (rather than fixing T upon initialization),
higher-order utility types must be explicitly defined.
TypeScript does not support higher-kinded types natively, but you can
assert the return type of _.memoize back to the original
function signature combined with _.MemoizedFunction:
import _ from 'lodash';
type AnyFunction = (...args: any[]) => any;
/**
* Wraps _.memoize while forcing the returned function to maintain
* the exact generic signature of the source function.
*/
function createGenericMemoize<F extends AnyFunction>(
fn: F,
resolver?: F extends (...args: infer P) => any ? (...args: P) => any : never
): F & _.MemoizedFunction {
return _.memoize(fn, resolver as any) as F & _.MemoizedFunction;
}Now consider a complex generic function using conditional types and tuple mapping:
type DeepTransform<T> = T extends object
? { [K in keyof T]: DeepTransform<T[K]> }
: T[];
interface DataStructure<A, B> {
alpha: A;
beta: B;
}
const processComplexData = <A extends string, B extends number>(
config: DataStructure<A, B>,
multiplier: B
): DeepTransform<DataStructure<A, B>> => {
return {
alpha: [config.alpha] as any,
beta: (config.beta * multiplier) as any,
} as DeepTransform<DataStructure<A, B>>;
};
// Memoizing the generic function
const memoizedProcess = createGenericMemoize(
processComplexData,
(config, multiplier) => `${config.alpha}-${multiplier}`
);
// The resulting function retains its full generic constraints:
// memoizedProcess: <A extends string, B extends number>(config: DataStructure<A, B>, multiplier: B) => DeepTransform<DataStructure<A, B>>
const result = memoizedProcess({ alpha: 'item', beta: 42 }, 2);Solution 3: Typing the Custom Cache Resolver
A common pitfall is typing the second argument
(resolver). If the generic function takes a complex
signature, the resolver must strictly match the inferred parameter
list.
Use TypeScript's Parameters<T> helper to enforce
parameter consistency on the resolver:
import _ from 'lodash';
export function memoizeWithStrictResolver<
Fn extends (...args: any[]) => any,
R extends (...args: Parameters<Fn>) => string
>(fn: Fn, resolver: R): Fn & _.MemoizedFunction {
return _.memoize(fn, resolver);
}If the function relies on variadic tuple types and generic constraints:
type GenericCallback<TArgs extends readonly unknown[], TReturn> = (
...args: TArgs
) => TReturn;
function memoizeVariadic<TArgs extends readonly unknown[], TReturn>(
fn: GenericCallback<TArgs, TReturn>,
resolver?: (...args: TArgs) => string
): GenericCallback<TArgs, TReturn> & _.MemoizedFunction {
return _.memoize(fn, resolver as any) as GenericCallback<TArgs, TReturn> & _.MemoizedFunction;
}Summary of Best Practices
- Avoid direct usage of
_.memoizeon uninstantiated generics: Passing a function with unassigned type variables directly to_.memoizecauses TypeScript to resolve those parameters to their upper bounds immediately. - Use wrapper factories: Create a helper function where generic type parameters are declared on the wrapper if the instance needs specific typing.
- Use type assertions via
as F & _.MemoizedFunction: When retaining an uninstantiated generic call signature across multiple calls is required, explicitly cast the output of_.memoizeback to the generic type definition.