I am interested in an approach to this problem:
We are given an abstract class such as:
export abstract class Middleware<T, K> {
public abstract run(input: T): Promise<K>;
}
Where T is the input type, and K is the output type of run()
. This abstract class is implemented by multiple specific middlewares. All middlewares are stored in an array, for sequential execution, such as:
[
specificMiddleware1,
specificMiddleware2,
specificMiddleware3
]
Where the output type K
of specificMiddleware1
will be the same as the input type T
of specificMiddleware2
, and so on.
All middlewares should be added to this array, so this is the best place to enforce type safety.
How could I enforce the return type of an array element to be the input type of the next one. Is there any way to do this in typescript?
Right now, I am using a more manual approach, where I have to manually maintain the input and output types:
export type InputType = //some type;
export type OutputType1 = //some type;
export type OutputType2 = //some type;
export type OutputType3 = //some type;
export interface Schema {
middlewares: [
// specificMiddleware1
Middleware<InputType, OutputType1>,
// specificMiddleware2
Middleware<OutputType1, OutputType2>,
// specificMiddleware3
Middleware<OutputType2, OutputType3>,
]
}
This works fine, but gets more and more cumbersome as more middlewares are added.
This also allows specificMiddleware1
to have a return type that does not match the input type of specificMiddleware2
, so I am losing type safety there.
Edit 1 I have tried out this awkward thing, but operations like these do not work in types, and I don't think this is feasible in a generic way. Probably I could make it work by knowing all types.
type Numbers = { 0; 1; 2; };
export type MiddlewareList = {
[Index in keyof Numbers]: Middleware<
ReturnType<MiddlewareList[Index - 1]['run']>,
unknown
>;
};
Edit 2: I have achieved a rather crude solution, and I will post it here for visibility. If you have any suggestions, I'm open to hearing them
// Returns the type T from Promise<T>
export type ExtractPromiseResult<T = PromiseLike<any>> = T extends PromiseLike<infer R> ? R : never;
// Returns the type K from
// public abstract run(input: T): Promise<K>;
export type ExtractReturnType<T> = ExtractPromiseResult<ReturnType<T['run']>>
export type MiddlewareChain<
InputType,
Middleware1 extends Middleware<unknown, unknown>,
Middleware2 extends Middleware<unknown, unknown> = never, // using never as default allows it to be optional
Middleware3 extends Middleware<unknown, unknown> = never, // using never as default allows it to be optional
// ... extend as needed
> = [
Middleware<InputType, ExtractReturnType<Middleware1>>,
Middleware<ExtractReturnType<Middleware1>, ExtractReturnType<Middleware2>>?,
Middleware<ExtractReturnType<Middleware2>, ExtractReturnType<Middleware3>>?,
// ... extend as needed
];
If you'd like to transform a tuple of types like [string, number, boolean, Date]
into a corresponding chain of Middleware
types like [Middleware<string, number>, Middleware<number, boolean>, Middleware<boolean, Date>]
, you can do so via a (tail-)recursive conditional type that uses variadic tuple types. Here's one way:
type MiddlewareList<
T extends readonly any[],
M extends readonly Middleware<any, any>[] = []
> = T extends readonly [infer T0, infer T1, ...infer R] ?
MiddlewareList<[T1, ...R], [...M, Middleware<T0, T1>]> :
M
The type MiddlewareList<T, M>
takes a tuple type T
and an accumulator tuple M
which is intended to start off as empty (hence the default type argument of the empty tuple []
, with M = []
) and which accumulates intermediate results.
It uses conditional type inference with variadic tuple types to parse the T
tuple into its first two elements T0
and T1
, and the rest of the tuple R
. If that fails then there are fewer than two elements in the list and we just return our accumulator M
. If it succeeds then we recursively call MiddlewareList<[T1, ...R], [...M, Middleware<T0, T1>]>
. That is, we drop only T0
from the beginning of T
, and we append Middleware<T0, T1>
to the end of M
.
First, let's make sure this works before stepping through it:
type Z = MiddlewareList<[string, number, boolean, Date]>;
// type Z = [
// Middleware<string, number>, Middleware<number, boolean>, Middleware<boolean, Date>
// ]
Looks good. Okay, now let's walk through the evaluation step-by-step so you can hopefully see how it works:
MiddlewareList<[string, number, boolean, Date]>`
evaluates to
MiddlewareList<[string, number, boolean, Date], []>`
because of the default type argument. That then evaluates to
[string, number, boolean, Date] extends readonly [infer T0, infer T1, ...infer R] ?
MiddlewareList<[T1, ...R], [...[], Middleware<T0, T1>]> : []
The conditional check succeeds with T0
being string
, T1
being number
, and R
being [boolean, Date]
(note that mutable tuple types are seen as assignable to readonly
tuple types), so it then becomes
MiddlewareList<[number, ...[boolean, Date]], [...[], Middleware<string, number>]>
which, when we evaluate those variadic tuples to normal forms, is
MiddlewareList<[number, boolean, Date], [Middleware<string, number>]>.
So in the first step, string
was shifted off the front of T
and used along with number
to make a Middleware<string, number>
type pushed onto the end of M
.
Analogously you can hopefully see how this then becomes
MiddlewareList<
[boolean, Date],
[Middleware<string, number>, Middleware<number, boolean>]
>
and then
MiddlewareList<
[Date],
[Middleware<string, number>, Middleware<number, boolean>, Middleware<boolean, Date>]
>
at which point the conditional type check fails, because [Date]
does not extend readonly [infer T0, infer T1, ...infer R]
. And so this just evaluates to M
alone, which is now the full accumulated
[Middleware<string, number>, Middleware<number, boolean>, Middleware<boolean, Date>]
as desired.