How to type a function that either runs or mocks another typed method

90 Views Asked by At

I am trying to establish typings for what should be a fairly simple Typescript case, but something is not bound correctly. I have Actions that return typed Reactions. Complementing an Action in the framework is a Performer. This function takes an Action and returns a corresponding Reaction (potentially mocking the Action).

However, the equality tests and type predicates which I'm using within the mocking logic of a Performer (to check if an Action should be intercepted and mocked) seem to not be properly coupled to the typing of the Performer itself, leading to compilation errors.

Can anyone spot which extra Generic bindings are needed to eliminate the compilation problems I face?

I created a minimal repro as below. The declaration of MOCK_PERFORM raises a compilation error 'Reaction' could be instantiated with an arbitrary type which could be unrelated to 'string' as if the type predicate isn't able to be exploited by the compiler to indicate that 'string' is a legitimate Reaction type from the Action.

/** DEFINE ACTION AND PERFORMER */

//Defines act() - a method which produces a Reaction
export interface Action<Reaction> {
  act: () => Reaction | Promise<Reaction>;
}

//Performer takes an action, returns a Reaction, (e.g. by running or mocking act())
type Performer = <Reaction>(action:Action<Reaction>) => Promise<Reaction>;


/** MINIMAL ACTION DEFINITION AND MOCKING SCENARIO */

class ProduceStringAction implements Action<string> {
    constructor(readonly value:string){}
    act() {
        return Promise.resolve(this.value)
    }
}
const expectedAction = new ProduceStringAction("hello");
const mockedReaction = "hello";

/** IDENTITY, TYPE PREDICATE FOR AN ACTION */

export function actionMatches<
  Reaction,
  Expected extends Action<Reaction>,
>(actual: Action<any>, expected: Expected): actual is Expected {
  return (
    actual.constructor === expected.constructor &&
    JSON.stringify(actual) === JSON.stringify(expected)
  );
}

/** EXAMPLE PERFORMERS */

//Act performer is simple - always calls act() to produce a Reaction
const ACT_PERFORM:Performer =  async (action) => await action.act();

//Mock performer tries to intercept a specific action, mock its reaction.
const MOCK_PERFORM:Performer = async (action) => {
    if(actionMatches(action, expectedAction)){
        return mockedReaction
    }
    return await ACT_PERFORM(action)
}

const value = MOCK_PERFORM(new ProduceStringAction("hello"))

It can be experimented with at this Typescript playground

1

There are 1 best solutions below

0
On

I found a solution which compiles and runs with the expected return types respecting the Reaction type for a given Action.

Introducing an explicit Action type for the action argument to MOCK_PERFORM was enough to stop the compiler going down a type-narrowing rabbithole creating a too-narrow Reaction type, and preventing the mocked Reaction from being allowed.

The proof is that the type of both mockedResult and realResult are properly inferred to be string and that the code below can run both with and without mocking to produce the same result.

/** DEFINE ACTION AND PERFORMER */

//Defines act() - a method which produces a Reaction
interface Action<Reaction> {
  act: () => Reaction | Promise<Reaction>;
}

//Performer takes an action, returns a Reaction, (e.g. by running or mocking act())
type Performer = <Reaction>(action:Action<Reaction>) => Promise<Reaction>;


/** MINIMAL ACTION DEFINITION AND MOCKING SCENARIO */

class ProduceStringAction implements Action<string> {
    constructor(readonly value:string){}
    act() {
        return Promise.resolve(this.value)
    }
}
const expectedAction = new ProduceStringAction("hello");
const mockedReaction = "hello";

/** IDENTITY, TYPE PREDICATE FOR AN ACTION */

function actionMatches<
  Reaction,
  Expected extends Action<Reaction>,
>(actual: Action<any>, expected: Expected): actual is Expected {
  return (
    actual.constructor === expected.constructor &&
    JSON.stringify(actual) === JSON.stringify(expected)
  );
}

/** EXAMPLE PERFORMERS */

//Act performer is simple - always calls act() to produce a Reaction
const ACT_PERFORM:Performer =  async (action) => await action.act();

//Mock performer tries to intercept a specific action, mock its reaction.
const MOCK_PERFORM:Performer = async (action:Action<any>) => {
    if(actionMatches(action, expectedAction)){
        return mockedReaction
    }
    return await ACT_PERFORM(action)
}

async function testOut(){
  const mockedResult = await MOCK_PERFORM(new ProduceStringAction("hello"))
  const realResult = await ACT_PERFORM(new ProduceStringAction("hello"));
  console.log(`Real '${realResult}' Mocked '${mockedResult}'`)
}

testOut()

Typescript playground for solution