Home > Net >  How to implement multi-level generics in Typescript?
How to implement multi-level generics in Typescript?

Time:01-01

I am working on a simple activity execution framework where developers define an activity that can be executed from a workflow.

To ensure type safety and improve developer productivity (with type hints), I would like to leverage OOP and generics in Typescript.

I have following abstract BaseActivity class.

export abstract class BaseActivity<ActivityInput, ActivityOutput> {
  public abstract execute(input: ActivityInput): ActivityOutput
}

With this, developers can define an activity like this:

type AddNumbersInput = {
  num1: number
  num2: number
}

export class AddNumbers extends BaseActivity<AddNumbersInput, number> {
  execute(input: AddNumbersInput): number {
    return input.num1   input.num2
  }
}

Now, I want to implement an ActivityExecutor that can preserve ActivityInput and ActivityOutput.

ActivityExecutor does quite a bit more work in the background in addition to calling execute of a perticular activity.

What I am struggling with is that the ActivityInput and ActivityOutput is unknown at design time. What are some patterns / strategies to overcome this challenge?

Obviously, the following code doesn't work. But, hopefully it gives enough idea on what I am trying to achieve conceptually.

export class ActivityExecutor<A extends BaseActivity<ActivityInput, ActivityOutput>> {
  execute(input: ActivityInput): ActivityOutput {
    return new A().execute(input)
  }
}

// Intention is to recieve the following type hints:
// input for `addNumbers.execute` needs to be `AddNumbersInput` and
// Output of `addNumbers.execute` is `number`
const addNumbers = new ActivityExecutor<AddNumbers>()
const result = addNumbers.execute({num1: 3, num2: 3})

CodePudding user response:

In order for this to work, the ActivityExecutor class needs to have a reference to a class constructor for the activity it's going to use, otherwise there will be nothing at runtime for it to call new on. So you could define it like this:

class ActivityExecutor<I, O> {
    constructor(public Activity: new () => BaseActivity<I, O>) { }
    execute(input: I): O {
        return new this.Activity().execute(input)
    }
}

where ActivityExecutor is generic in the same input I and output O type arguments from BaseActivity. The constructor takes an Activity argument whose type is a zero-argument construct signature for a BaseActivity<I, O>. The public modifier makes it a parameter property, so that the parameter is copied into a class property of the same name. It's essentially the same as

class ActivityExecutor<I, O> {
    Activity;
    constructor(Activity: new () => BaseActivity<I, O>) {
        this.Activity = Activity;
    }
    execute(input: I): O {
        return new this.Activity().execute(input)
    }
}

Anyway, the execute() method now has access to the needed this.Activity class constructor so it can be implemented.

Let's make sure it works:

const addNumbers = new ActivityExecutor(AddNumbers);
// const addNumbers: ActivityExecutor<AddNumbersInput, number>
const result = addNumbers.execute({ num1: 3, num2: 3 });
// const result: number
console.log(result) // 6

Looks good. The compiler understands that new ActivityExecutor(AddNumbers) produces a value of type ActivityExecutor<AddNumbersInput, number>, and thus that the execute() method takes an AddNumbersInput argument and produces a number output.

Playground link to code

  • Related