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.