Skip to main content

TS Ep 32: Multiple & Dependent Generic Parameters

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
typescript - This article is part of a series.
Part 32: This Article
Generics are rarely isolated to a single <T>. Complex domain functions frequently require multiple interacting generic type parameters (<T, U, V>) to express dependencies between arguments, keys, and return types.

1. Multiple Generic Parameters (<T, U>)
#

When a function processes multiple arguments with distinct types, define separate type parameters separated by commas inside the angle brackets:

// 'T' captures first object, 'U' captures second object
function mergeData<T extends object, U extends object>(first: T, second: U): T & U {
  return { ...first, ...second };
}

const user = { id: "usr_101", name: "Alice" };
const permissions = { isAdmin: true, roles: ["editor"] };

// TypeScript automatically infers T = typeof user, U = typeof permissions
const combined = mergeData(user, permissions);

// Inferred Return Type: { id: string; name: string } & { isAdmin: boolean; roles: string[] }
console.log(combined.name, combined.roles);

2. Dependent Generic Parameters
#

A Dependent Generic Parameter is a generic type parameter whose constraint relies on another generic type parameter declared earlier in the same signature.

// 'K' is dependent on 'T' because its constraint is 'keyof T'
// 'V' is dependent on 'T' and 'K' because its constraint is 'T[K]'
function mapProperty<T extends object, K extends keyof T, V extends T[K]>(
  obj: T,
  key: K,
  transform: (val: T[K]) => V
): T {
  const currentVal = obj[key];
  const newVal = transform(currentVal);
  return {
    ...obj,
    [key]: newVal,
  };
}

const account = { id: 101, username: "john_doe", balance: 500 };

// 🟢 T = typeof account, K = "balance", V = number
const updatedAccount = mapProperty(account, "balance", (b) => b + 250);
console.log(updatedAccount.balance); // 750

3. The Partial Generic Inference Trap
#

One of TypeScript’s most infamous design limitations is the Partial Generic Inference Trap.

The Rule:
#

In TypeScript, generic inference is all-or-nothing. If a function has 2 generic parameters <T, U>, you must either let TypeScript infer BOTH parameters automatically, or you must specify BOTH parameters manually. You CANNOT manually supply T and expect TypeScript to infer U!

Demonstrating the Trap:
#

function makeFetcher<TData, TParams>(url: string, params: TParams): Promise<TData> {
  return fetch(`${url}?${new URLSearchParams(params as any)}`).then((res) => res.json());
}

interface UserResponse {
  id: string;
  name: string;
}

// ❌ COMPILER ERROR!
// We want to pass <UserResponse> for TData, but let TS infer TParams from { page: 1 }!
// TypeScript REQUIRES us to supply BOTH <UserResponse, { page: number }> !
/*
const data = makeFetcher<UserResponse>(
  "https://api.acme.com/users", 
  { page: 1 }
);
*/

4. Solving the Trap: Curried Higher-Order Functions
#

To solve the Partial Inference Trap and allow passing TData manually while inferring TParams automatically, split the generic parameter list into two curried functions:

// Function 1 accepts the explicit type parameter TData
function createFetcher<TData>() {
  // Function 2 accepts arguments and infers TParams automatically!
  return function executeFetch<TParams>(url: string, params: TParams): Promise<TData> {
    return fetch(`${url}?${new URLSearchParams(params as any)}`).then((res) => res.json());
  };
}

interface UserResponse {
  id: string;
  name: string;
}

// 🟢 WORKED PERFECTLY!
// 1. First call explicitly sets TData = UserResponse
// 2. Second call automatically infers TParams = { page: number; limit: number }
const fetchUsers = createFetcher<UserResponse>();

const userPromise = fetchUsers("https://api.acme.com/users", { page: 1, limit: 10 });
// Inferred Return Type: Promise<UserResponse>

Why Currying Solves the Trap:
#

By separating createFetcher<TData>() from executeFetch<TParams>(), each function signature only has one generic parameter. The first function handles explicit manual type passing, while the inner function handles automatic argument type inference!


5. Generic Map Type Example (Map<K, V>)
#

class CustomCache<TKey extends string | number, TValue> {
  private store = new Map<TKey, TValue>();

  public set(key: TKey, value: TValue): void {
    this.store.set(key, value);
  }

  public get(key: TKey): TValue | undefined {
    return this.store.get(key);
  }
}

// Instantiate with 2 explicit generic parameters:
const cache = new CustomCache<string, { id: number; data: string }>();
cache.set("session_1", { id: 101, data: "Payload" });

const item = cache.get("session_1"); // Inferred Type: { id: number; data: string } | undefined

Summary & Next Steps
#

In this episode:

  • We defined functions and classes with multiple generic parameters (<T, U, V>).
  • We built Dependent Generic Parameters (<T, K extends keyof T>).
  • We investigated the Partial Generic Inference Trap (All-or-Nothing inference rule).
  • We solved the Partial Inference Trap using Curried Higher-Order Functions.

In Episode 33: Utility Types (Partial, Required, Readonly), we will explore TypeScript’s built-in type mapping transformations!

typescript - This article is part of a series.
Part 32: This Article