0

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

cefn
  • 2,895
  • 19
  • 28
  • Experimenting further [this playground](https://tsplay.dev/mZ8RJw) underlines there's a stray type. It generates the error `Type 'Reaction' is not assignable to type 'Reaction'. Two different types with this name exist, but they are unrelated`. I think the uncoupled types are the inferred Reaction from the passed action, (the generic Reaction defined in the Performer declaration) and the inferred Reaction from the mockedAction (the generic Reaction defined in the actionMatches() declaration). These need to be coupled in some third place in the mocking scenario but I can't figure where or how. – cefn May 02 '21 at 10:20
  • My expectation was that they would be coupled through inference, but this isn't happening so the coupling needs to be hoisted into an explicit generic somewhere. – cefn May 02 '21 at 10:21
  • Approaching it from another direction [this playground](https://tsplay.dev/Nd3xyw) DOES compile, but at the cost of having the default performer typed like... `const ACT_PERFORM: (action: Action) => Promise` and therefore with `realResult` being `any` instead of `string` as it should be. So perhaps narrowing the types from there until the return type of `ACT_PERFORM` can be inferred as string is another way of approaching the problem. – cefn May 02 '21 at 10:52

1 Answers1

0

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

cefn
  • 2,895
  • 19
  • 28