Search code examples
node.jstypescriptmiddleware

Typescript: Typing an array of sequential middleware-like classes


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
];

Solution

  • 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.

    Playground link to code