TypeScript: typing a mock function

298 views Asked by At

I created a simple helper mock function. I'm currently struggling with getting the types right. Here is what I have so far:

import includes from 'ramda/src/includes';

function fn<T, Y extends any[]> (implementation: ((...args: Y) => T) = () => {}) {
  const mockFn = (...args: Y) => {
    mockFn.calls.push(args);
    return implementation(...args);
  };
  mockFn.calls = [];

  mockFn.hasBeenCalled = () => mockFn.calls.length > 0;
  mockFn.hasBeenCalledWith = (...args: Y) => includes(args, mockFn.calls);

  return mockFn;
}

Here is a playground example.

TypeScript complains at two places.

Firstly, it complains about implementation saying:

Type '() => void' is not assignable to type '(...args: Y) => T'.
  Type 'void' is not assignable to type 'T'.
    'T' could be instantiated with an arbitrary type which could be unrelated to 'void'.

Secondly, it complains about mockFn.calls that

Member 'calls' implicitly has an 'any[]' type.

The mocked function should be used like this:

// with implementation
const originalFunction = (a: number, b: number) => a + b; // e.g. a simple add function
const mockedFn = fn(originalFunction);

mockedFn.hasBeenCalled();
// false

mockedFn(21, 21);
// 42

mockedFn.hasBeenCalled();
// true

mockedFn.hasBeenCalledWith(21);
// false

mockedFn.hasBeenCalledWith(21, 21);
// true

But it should also work without implementation (hence the default to () => {}).

const mockFn = fn();

// etc.

It would be cool if TypeScript could know that mockedFn has the same function signature as originalFunction, but additionally exposing .calls, hasBeenCalled and hasBeenCalledWith.

In my current implementation, it seems to know about hasBeenCalled and hasBeenCalledWith saying they are of type:

mockFn.hasBeenCalled(): boolean
mockFn.hasBeenCalledWith(...args: Y): boolean

How can I fix these type errors so that TypeScript knows about fns capabilities?

1

There are 1 answers

0
Aleksey L. On BEST ANSWER

You could use single generic parameter representing the function and use Parameters and ReturnType utilities with it:

function fn<T extends (...args: any[]) => any> (implementation?: T) {
  const mockFn = (...args: Parameters<T>): ReturnType<T> => {
    mockFn.calls.push(args);
    return implementation?.(...args);
  };
  mockFn.calls = [] as (Parameters<T>)[];

  mockFn.hasBeenCalled = () => mockFn.calls.length > 0;
  mockFn.hasBeenCalledWith = (...args: Parameters<T>) => includes(args, mockFn.calls);

  return mockFn;
}

Playground


First error says that it is possible to call function with explicit generic parameters (e.g. fn<number, []>()) and the default value (() => {}) won't supply the required return type. To fix this optional chaining was used instead of default value