Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to type chain of Middlewares and Controller correctly in typescript

Tags:

typescript

I asked this before but with function. I can't seem to find how to do this with class.

abstract class BaseMiddleware<
  Input extends Record<string, any>,
  Output extends Record<string, any>,
> {
  abstract index(data: Input): Output
}

abstract class BaseController<Input extends Record<string, any>> {
  abstract index(data: Input): void
}

class AuthMiddleware extends BaseMiddleware<{}, { userID: number }> {
  index(data: {}) {
    return {
      userID: 1,
    }
  }
}

class UserPopulateMiddleware extends BaseMiddleware<{ userID: number }, { username: string }> {
  index(data: { userID: number }) {
    return {
      username: `getUsernameFromUserID(${data.userID})`,
    }
  }
}

class AnalyticMiddleware extends BaseMiddleware<{ userID: number; username: string }, {}> {
  index(data: { userID: number; username: string }) {
    return {}
  }
}

class AuthorizedController extends BaseController<{ userID: number }> {
  index(data: { userID: number }) {}
}

class UserPopulatedController extends BaseController<{ userID: number; username: string }> {
  index(data: { userID: number; username: string }) {}
}

class Controller extends BaseController<{}> {
  index(data: {}) {}
}

// How to make sure that for every middleware to the next middleware and then to controller data parameter type is fullfilled
const route = (path: string, middlewares: BaseMiddleware<any, any>[], controller: BaseController<any>) => {
  // routing implementation
}

// This should not error because controller data requirement which is {userID: number} is fullfilled by the AuthMiddleware
route('test', [new AuthMiddleware()], new Controller())

// This should error because controller data requirement which is {userID: number} is not fullfilled because there is no middleware that supply it
route('test', [], new Controller())

// Same if there is more than 1 middleware previous middleware must correctly return data for the next middleware

route('test', [new AuthMiddleware(), new UserPopulateMiddleware()], new UserPopulatedController()) // Pass
route('test', [], new Controller()) // Pass
route('test', [], new UserPopulatedController()) // Error
route('test', [new AuthMiddleware()], new UserPopulatedController()) // Error
route('test', [new UserPopulateMiddleware()], new AuthorizedController()) // Error
route('test', [new UserPopulateMiddleware()], new AuthorizedController()) // Error
route('test', [], new AuthorizedController()) // Error

route('test', [new AuthMiddleware(), new AnalyticMiddleware()], new AuthorizedController()) // Error

Playground

like image 463
kennarddh Avatar asked Oct 24 '25 15:10

kennarddh


1 Answers

Not sure how to do it with vanilla TS, but if you're okay with using Hotscript, this is how you can do it:

import { Pipe, Fn, Tuples } from 'hotscript';

abstract class BaseMiddleware {
  abstract index(data: Record<string, any>): void
}

abstract class BaseController<D extends Record<string, any>> {
  abstract index(data: D): void
}

class AuthMiddleware extends BaseMiddleware {
  index(data: {}) {
    return {
      userID: 1
    }
  }
}

class RegionMiddleware extends BaseMiddleware {
  index(data: {}) {
    return {
      region: 'Europe'
    }
  }
}

class RequestsHistoryMiddleware extends BaseMiddleware {
  index(data: {userID: number}) {
    return {
      requestsInLastMinute: 42
    }
  }
}

class Controller extends BaseController<{userID:number}> {
  index(data: {userID:number}) {}
}

class AdvancedController extends BaseController<{userID:number, region: string}> {
  index(data: {userID:number, region: string}) {}
}

type MiddlewareInterface<I, O> = {input: I, output: O};

// This function extract input and output data types from middleware and puts them into single type
interface ExtractMiddlewareRequirement extends Fn {
  return: this["arg0"] extends BaseMiddleware 
    ? MiddlewareInterface<Parameters<this["arg0"]["index"]>[0], ReturnType<this["arg0"]["index"]>> 
    : never;
}

// This function checks if current input type satisfies middleware requirements, and if so -- returns input type combined with middleware output type
interface MiddlewareRequirementReducer extends Fn {
  return: this["arg0"] extends this["arg1"]["input"] ? this["arg0"] & this["arg1"]["output"] : never;
}

// This type verifies that all middlewares in chain has their input data requirements met and returns combined output data type (i.e. one you get after executing all middlewares)
type VerifyMiddlewareChain<Middlewares extends BaseMiddleware[]> = Pipe<
  Middlewares,
  [
    Tuples.Map<ExtractMiddlewareRequirement>,
    Tuples.Reduce<MiddlewareRequirementReducer, Record<string, never>>
  ]
>;

// This type checks if provided middlewares satisfy controller input data type
type CheckMiddlewaresTypes<Middlewares extends BaseMiddleware[], CD extends Record<string, any>> = 
  VerifyMiddlewareChain<Middlewares> extends never 
    ? never 
    : VerifyMiddlewareChain<Middlewares> extends CD
      ? Middlewares
      : never;


const route = <const Middlewares extends BaseMiddleware[], CD extends Record<string, any>>(path: string, middlewares: CheckMiddlewaresTypes<Middlewares, CD>, controller: BaseController<CD>) => {
  // routing implementation
}

// These work
route('test', [new AuthMiddleware()], new Controller()); // Middleware covers controller input type requirements
route('test', [new AuthMiddleware(), new RegionMiddleware()], new Controller()); // Middleware adds extra properties not required by controller
route('test', [new AuthMiddleware(), new RegionMiddleware(), new RequestsHistoryMiddleware()], new AdvancedController()); // Multiple middlewares when RequestsHistoryMiddleware depends on AuthMiddleware

// These should fail
route('test', [], new Controller()); // Not meeting controller requirements
route('test', [new RegionMiddleware()], new Controller()); // Not meeting controller requirements
route('test', [new RegionMiddleware(), new RequestsHistoryMiddleware()], new Controller()); // RequestsHistoryMiddleware required userID which isn't provided by prior middleware

Significant downside is that you don't get nice error messages telling what exactly didn't go as planned. You could try to partially solve it by using solution I described here

Playground

like image 146
OlegWock Avatar answered Oct 26 '25 05:10

OlegWock



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!