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

  1. Avoid direct usage of _.memoize on uninstantiated generics: Passing a function with unassigned type variables directly to _.memoize causes TypeScript to resolve those parameters to their upper bounds immediately.
  2. Use wrapper factories: Create a helper function where generic type parameters are declared on the wrapper if the instance needs specific typing.
  3. Use type assertions via as F & _.MemoizedFunction: When retaining an uninstantiated generic call signature across multiple calls is required, explicitly cast the output of _.memoize back to the generic type definition.