TypeScript ReturnType and Parameters: Function Types

TypeScript's utility types for functions solve a common problem: how do you reference a function's types without duplicating them? When you're building wrappers, decorators, or any abstraction around...

Key Insights

  • ReturnType<T> and Parameters<T> extract type information from existing functions, eliminating duplicate type definitions and keeping your code DRY
  • These utilities shine when building wrappers, decorators, and higher-order functions that need to preserve the original function’s type signature
  • Combine both utilities to create type-safe proxies, memoization helpers, and middleware systems without manual type annotations

Introduction to Utility Types for Functions

TypeScript’s utility types for functions solve a common problem: how do you reference a function’s types without duplicating them? When you’re building wrappers, decorators, or any abstraction around existing functions, you need to maintain type safety without copying type definitions everywhere.

Consider this typical scenario without utility types:

function fetchUser(id: string): Promise<User> {
  return api.get(`/users/${id}`);
}

// Without utility types, you duplicate the signature
function withLogging(
  id: string
): Promise<User> {
  console.log('Fetching user:', id);
  return fetchUser(id);
}

Every time fetchUser changes, you need to update withLogging. This breaks the DRY principle and creates maintenance headaches. TypeScript’s ReturnType and Parameters utilities extract this information directly from the function type, creating a single source of truth.

ReturnType - Extracting Return Types

ReturnType<T> extracts the return type of any function type. The syntax is straightforward: pass a function type, get back its return type.

function getUser(id: string) {
  return { id, name: 'John', email: 'john@example.com' };
}

type User = ReturnType<typeof getUser>;
// type User = { id: string; name: string; email: string; }

Notice the typeof operator. ReturnType expects a type, not a value, so you use typeof to get the function’s type from the function itself.

With generic functions, ReturnType infers the concrete return type based on the generic constraints:

function wrapInArray<T>(value: T): T[] {
  return [value];
}

type StringArray = ReturnType<typeof wrapInArray<string>>;
// type StringArray = string[]

type NumberArray = ReturnType<typeof wrapInArray<number>>;
// type NumberArray = number[]

Async functions return promises, and ReturnType captures that:

async function fetchData(): Promise<{ data: string[] }> {
  const response = await fetch('/api/data');
  return response.json();
}

type FetchDataReturn = ReturnType<typeof fetchData>;
// type FetchDataReturn = Promise<{ data: string[] }>

If you need the unwrapped type, combine with Awaited:

type UnwrappedData = Awaited<ReturnType<typeof fetchData>>;
// type UnwrappedData = { data: string[] }

Here’s a practical example—building a type-safe cache wrapper:

function createCachedFunction<F extends (...args: any[]) => any>(fn: F) {
  const cache = new Map<string, ReturnType<F>>();
  
  return (...args: Parameters<F>): ReturnType<F> => {
    const key = JSON.stringify(args);
    
    if (cache.has(key)) {
      return cache.get(key)!;
    }
    
    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}

const expensiveCalculation = (a: number, b: number) => a * b + Math.random();
const cached = createCachedFunction(expensiveCalculation);

// TypeScript knows cached returns a number
const result: number = cached(5, 10);

Parameters - Extracting Parameter Types

Parameters<T> extracts function parameters as a tuple type. This is invaluable for function composition and creating type-safe wrappers.

function createUser(name: string, email: string, age: number) {
  return { name, email, age };
}

type CreateUserParams = Parameters<typeof createUser>;
// type CreateUserParams = [name: string, email: string, age: number]

You can destructure tuple types to access individual parameters:

type FirstParam = Parameters<typeof createUser>[0];  // string
type SecondParam = Parameters<typeof createUser>[1]; // string
type ThirdParam = Parameters<typeof createUser>[2];  // number

With rest parameters, Parameters captures them correctly:

function sum(...numbers: number[]): number {
  return numbers.reduce((a, b) => a + b, 0);
}

type SumParams = Parameters<typeof sum>;
// type SumParams = [numbers: ...number[]]

Here’s a practical example—a type-safe event emitter:

type EventMap = {
  userCreated: (user: { id: string; name: string }) => void;
  userDeleted: (userId: string) => void;
  error: (error: Error, context: string) => void;
};

class TypedEventEmitter<Events extends Record<string, (...args: any[]) => void>> {
  private listeners = new Map<keyof Events, Set<Function>>();

  on<E extends keyof Events>(
    event: E,
    callback: Events[E]
  ): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(callback);
  }

  emit<E extends keyof Events>(
    event: E,
    ...args: Parameters<Events[E]>
  ): void {
    const callbacks = this.listeners.get(event);
    if (callbacks) {
      callbacks.forEach(callback => callback(...args));
    }
  }
}

const emitter = new TypedEventEmitter<EventMap>();

// Type-safe: TypeScript knows the parameter types
emitter.on('userCreated', (user) => {
  console.log(user.name); // ✓ TypeScript knows user has name
});

emitter.emit('userCreated', { id: '1', name: 'Alice' }); // ✓
// emitter.emit('userCreated', 'wrong'); // ✗ Type error

Combining ReturnType and Parameters

The real power emerges when you combine both utilities. This enables you to create sophisticated wrappers that preserve complete function signatures.

Here’s a type-safe retry utility:

async function withRetry<F extends (...args: any[]) => Promise<any>>(
  fn: F,
  maxAttempts: number = 3
): (...args: Parameters<F>) => ReturnType<F> {
  return async (...args: Parameters<F>): ReturnType<F> => {
    let lastError: Error;
    
    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
      try {
        return await fn(...args);
      } catch (error) {
        lastError = error as Error;
        if (attempt === maxAttempts) break;
        await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
      }
    }
    
    throw lastError!;
  };
}

async function fetchUserData(userId: string): Promise<{ name: string }> {
  const response = await fetch(`/api/users/${userId}`);
  return response.json();
}

const fetchWithRetry = withRetry(fetchUserData);

// TypeScript preserves the complete signature
const userData = await fetchWithRetry('123'); // Returns Promise<{ name: string }>

For memoization with proper typing:

function memoize<F extends (...args: any[]) => any>(fn: F): F {
  const cache = new Map<string, ReturnType<F>>();
  
  return ((...args: Parameters<F>): ReturnType<F> => {
    const key = JSON.stringify(args);
    
    if (cache.has(key)) {
      return cache.get(key)!;
    }
    
    const result = fn(...args) as ReturnType<F>;
    cache.set(key, result);
    return result;
  }) as F;
}

const fibonacci = memoize((n: number): number => {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
});

const result = fibonacci(10); // TypeScript knows this returns number

Practical Patterns and Best Practices

These utilities excel in several real-world scenarios. API client wrappers benefit significantly:

class ApiClient {
  async getUser(id: string) {
    return { id, name: 'John', role: 'admin' as const };
  }
  
  async updateUser(id: string, data: { name?: string }) {
    return { success: true };
  }
}

type ApiMethod = keyof ApiClient;

function createApiLogger<T extends ApiClient>(client: T) {
  return new Proxy(client, {
    get(target, prop: ApiMethod) {
      const original = target[prop];
      
      if (typeof original === 'function') {
        return async (...args: Parameters<typeof original>) => {
          console.log(`Calling ${String(prop)} with`, args);
          const result = await original.apply(target, args);
          console.log(`${String(prop)} returned`, result);
          return result as ReturnType<typeof original>;
        };
      }
      
      return original;
    }
  });
}

const client = new ApiClient();
const loggedClient = createApiLogger(client);

// Full type safety preserved
const user = await loggedClient.getUser('123');
console.log(user.role); // TypeScript knows role is 'admin'

For testing, create type-safe mock generators:

function createMock<F extends (...args: any[]) => any>(
  returnValue: ReturnType<F>
): jest.Mock<ReturnType<F>, Parameters<F>> {
  return jest.fn().mockReturnValue(returnValue);
}

function calculatePrice(quantity: number, price: number): number {
  return quantity * price;
}

const mockCalculate = createMock<typeof calculatePrice>(100);
mockCalculate(5, 20); // Fully typed
expect(mockCalculate).toHaveBeenCalledWith(5, 20);

When NOT to use these utilities: Don’t over-engineer simple scenarios. If you’re just calling a function directly without wrapping or transforming it, explicit types are clearer:

// Overkill - just use the function
type Result = ReturnType<typeof simpleFunction>;
const data: Result = simpleFunction();

// Better - direct and clear
const data = simpleFunction();

Conclusion

ReturnType and Parameters are essential tools for maintaining type safety in higher-order functions and wrappers. Use ReturnType<T> when you need to reference what a function returns without duplicating type definitions. Reach for Parameters<T> when building function wrappers, middleware, or event systems that need to preserve parameter types.

Quick reference:

  • ReturnType<typeof fn> - extracts return type
  • Parameters<typeof fn> - extracts parameters as tuple
  • Awaited<ReturnType<typeof asyncFn>> - unwraps Promise types
  • Combine both for complete signature preservation

These utilities keep your code DRY, make refactoring safer, and enable sophisticated patterns like decorators, proxies, and type-safe event systems. Master them, and you’ll write more maintainable TypeScript.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.