Search code examples
typescript

How to fix typescript types that is working on type but not when used with function


Route function always error the middlewares parameter type. But it's not error if the type is used directly like on line 72 and 75.

But on line 107 it will be error. Or on line 98 it also always error.

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: {}) {}
}

type MiddlewareArray = BaseMiddleware<any, any>[]

type ValidateMiddlewares<
  T extends MiddlewareArray,
  V extends MiddlewareArray = T,
  Input extends Record<string, any> = Record<string, never>,
  Results extends any[] = [],
> = V extends [BaseMiddleware<Input, infer Output>, ...infer Tail extends MiddlewareArray]
  ? ValidateMiddlewares<T, Tail, Input & Output, [...Results, V[0]]>
  : V extends [BaseMiddleware<any, infer Output>, ...infer Tail extends MiddlewareArray]
  ? ValidateMiddlewares<T, Tail, Input, [...Results, BaseMiddleware<Input, Output>]>
  : Results

type MergeMiddlewaresOutput<
  T extends MiddlewareArray,
  Input extends Record<string, any> = {},
> = T extends [BaseMiddleware<any, infer Output>, ...infer Tail extends MiddlewareArray]
  ? MergeMiddlewaresOutput<Tail, Output & Input>
  : Input

type R1 = ValidateMiddlewares<
  [BaseMiddleware<{}, { token: string }>, BaseMiddleware<{ a: string }, { userID: number }>]
>
type R2 = ValidateMiddlewares<
  [BaseMiddleware<{}, { userID: number }>, BaseMiddleware<{ userID: number }, { username: string }>]
>
type R3 = ValidateMiddlewares<[AuthMiddleware, UserPopulateMiddleware]>
type R4 = ValidateMiddlewares<[UserPopulateMiddleware]>
type R5 = ValidateMiddlewares<[AuthMiddleware, AnalyticMiddleware]>

// This is working as expected. Only AnalyticMiddleware is underlined by red line. As it needs userID and username input but AuthMiddleware only output userID
const R5: ValidateMiddlewares<[AuthMiddleware, AnalyticMiddleware]> = [
  new AuthMiddleware(),
  new AnalyticMiddleware(),
]

// This is working as expected.
const R6: ValidateMiddlewares<[AuthMiddleware, UserPopulateMiddleware, AnalyticMiddleware]> = [
  new AuthMiddleware(),
  new UserPopulateMiddleware(),
  new AnalyticMiddleware(),
]

type ValidateController<
  Controller extends BaseController<{}>,
  Middlewares extends MiddlewareArray | [],
> = Controller extends BaseController<infer Data>
  ? MergeMiddlewaresOutput<Middlewares> extends Data
    ? Controller
    : never
  : never

type C1 = ValidateController<
  BaseController<{ userID: number }>,
  [BaseMiddleware<{}, { userID: number; username: string }>]
>

// This is the function
const route = <Middlewares extends MiddlewareArray, Controller extends BaseController<any>>(
  path: string,
  middlewares: ValidateMiddlewares<Middlewares>,
  controller: ValidateController<Controller, Middlewares>,
) => {
  // routing implementation
}

route('test', [new AuthMiddleware(), new UserPopulateMiddleware()], new UserPopulatedController()) // Should 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

// This error as expected but instead of underlining the AnalyticMiddleware. It does it to the whole array.
route('test', [new AuthMiddleware(), new AnalyticMiddleware()], new AuthorizedController()) // Error

Playground


Solution

  • If you have a generic function and want to have the compiler infer the type arguments given the function arguments, then you need to make sure that the function parameter types are very simply related to the generic type parameter. To infer T from a value of type F<T>, the compiler needs to be able to "invert" F. The more complicated F is, the less possible that is.

    Your ValidateMiddlewares and ValidateController types are recursive conditional types, complicated things where it's not obvious how you would expect the compiler to invert it. By inspection, I suppose you want ValidateMiddlewares<T> and ValidateController<T, U> to be assignable to T if T is correct, and not otherwise. That is, they are named ValidateXXX for a reason (which we can't expect the compiler to understand, of course).

    If so, then what you really want is to infer T as the type of the argument, and then check that the argument is also assignable to ValidateXXX<T>. You can do this a number of ways, but the easiest is to use the work done in microsoft/TypeScript#8821 and write T & ValidateXXX<T> in place of ValidateXXX<T>. The compiler will infer from an intersection so that T will be inferred as the input type. Then it will check against T & ValidateXXX<T>. We know that T will succeed against T, so then this becomes equivalent to checking against ValidateXXX<T>.

    Of course if the check fails, the intersection is likely to be the never type, which might not be as pretty of an error message as you'd like. There are ways around this, but they are more complicated and depend strongly on the code, and the code in this example is already longer than I'd like to see for a Stack Overflow question. So I'll leave it here with an intersection:

    const route = <Middlewares extends MiddlewareArray, Controller extends BaseController<any>>(
      path: string,
      middlewares: Middlewares & ValidateMiddlewares<Middlewares>, // here
      controller: Controller & ValidateController<Controller, Middlewares>, // here
    ) => {
      // routing implementation
    }
    

    And you can see that it suddenly behaves as expected:

    route('test', [new AuthMiddleware(), new UserPopulateMiddleware()], new UserPopulatedController()) // okay
    route('test', [], new Controller()) // okay
    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 link to code