typescriptgenericsnarrowing

Typescript infer narrowed generic based on dynamic plugins


I'm trying to build an express alternative that is based on plugins. Routes are defined with a definition and a handler function.

Each plugin can add requirements that each route definition need to provide (options). Then based on the definition, each plugin can add stuff to the context object provided to the handler. The code is looking something like this (tried to shrink it down as much as possible).

// How it's supposted to work:
const server = createServer([auth, validation])

server.route(
    {
        // Will add url, method etc. to this object
        roles: ['customer', 'user'],  // <-- required by the auth plugin
        headers: {
            'x-string-header': 'string',
            'x-number-header': 'number'
        },
    },
    (context, definition) => {
        // should complain
        definition.roles.includes('public') // <-- definition.roles correctly excludes 'public' from the type, since it's narrowed down to the actual provided definition
        context.user.role === 'public' // <-- context.user.role should be "customer" | "user", since those are the only roles that can access this route

        // should not complain
        return context.headers['x-string-header'] // <-- Should be string (inferred from validation schema)
    }
)



///// TYPES /////
// UnionToIntersection is copied from https://stackoverflow.com/a/50375286, thanks @jcalz!
type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends ((x: infer I) => void) ? I : never

type PluginFn<TDefinition extends object, TContext extends object> = (definition: TDefinition, request: Request) => TContext

type PluginContext<TPlugin extends PluginFn<TDefinition, object>, TDefinition extends object> = UnionToIntersection<TPlugin extends PluginFn<TDefinition, infer TContext> ? TContext : never>

type PluginOptions<TPlugin extends PluginFn<object, object>> = UnionToIntersection<TPlugin extends PluginFn<infer TDefinition, any> ? TDefinition : never>


///// MAIN FUNCTIONS /////
function createServer<TPlugin extends PluginFn<any, any>>(plugins: TPlugin[]) {

    function route<const TDefinition extends PluginOptions<TPlugin> & object, const TContext extends PluginContext<TPlugin, TDefinition>>(
        definition: TDefinition,
        handler: (
            context: TContext,
            definition: TDefinition
        ) => string | object | Promise<string> | Promise<object>
    ) {
        return { definition, handler }
    }

    return {
        route,
        plugins
    }
}


///// AUTH PLUGIN //////
type AuthOptions = {
    roles: Array<'user' | 'public' | 'customer'>,
}

type User<T extends AuthOptions> = {
    user: {
        role: T["roles"][number],
        permissions: string[]
    }
}

function auth<const T extends AuthOptions>(route: T, request: Request): User<T> {
    // TODO: Extract token from request, validate and throw Unathorized if user has insufficient permission/role
    return {
        user: {
            role: 'user', // <-- TODO: extract from token
            permissions: [] // <-- TODO: extract from token
        }
    }
}


///// VALIDATION PLUGIN //////
type ValidationOptions = {
    headers?: Record<string, 'string' | 'number'>
}

type ValidationResult<T extends ValidationOptions> = {
    headers: T["headers"] extends Record<string, any>
    ? {
        [key in keyof T["headers"]]: T["headers"][key] extends 'string'
        ? string
        : T["headers"][key] extends 'number'
        ? number
        : never
    }
    : undefined
}

function validation<const T extends ValidationOptions>(route: T, request: Request): ValidationResult<T> {
    if (!route.headers) {
        return { headers: undefined } as ValidationResult<T>
    }
    const entries = Object.entries(route.headers).map(([key, type]) => {
        if (type === 'number') {
            return [key, Number(request.headers.get(key))]
        }
        return [key, String(request.headers.get(key))]
    })
    return {
        headers: Object.fromEntries(entries)
    }
}

Feel free to play around with the example in the Typescript playground.

My issue right now is that the context object provided to the handler function in server.route is not narrowed down based on the definition, but rather the broader type User<AuthOptions> & ValidationResult<ValidationOptions>. Is it possible to narrow it down?


Solution

  • What you're trying to do is mostly beyond TypeScript's abilities to represent.

    You want to call something like createServer(plugin1, plugin2, plugin3) where plugin1, plugin2, and plugin3 are generic functions of the form, <T extends C1>(def: T, req: Request) => F1<T>, <T extends C2>(def: T, req: Request) => F2<T>, and <T extends C3>(def: T, req: Request) => F3<T>, respectively. The problem is that there's no way to abstract over that to define type PluginFn<C, F> = <T extends C>(def: T, req: Request) => F<T>, because that F wouldn't be a type. You want F to be an arbitrary generic type function that itself takes a type parameter of the form T extends C. TypeScript lacks direct support for representing arbitrary type functions. In order to do this, you'd probably want what's known as higher kinded types, as requested in microsoft/TypeScript#1213.

    Without getting too hung up on syntax, you're trying to say something like

    // not valid TS, don't try it
    type PluginFn<F extends HKT> = <T extends ConstraintOf<F>>(def: T, req: Request) => F<T>
    

    where that extends HKT means something like "it's a higher kinded type that takes a type argument", and ConstraintOf would result in the constraint for a generic function type. (So ConstraintOf<typeof auth> would be AuthOptions`, for example). And then you'd write

    // not valid TS, don't try it
    declare function createServer<F extends HKT[]>(...plugins: F): {
        route: <const D extends TupleToIsect<{ [I in keyof F]: ConstraintOf<F[I]> }>>(
            definition: D,
            handler: (
                context: TupleToIsect<{ [I in keyof F]: F[I]<D> }>,
                definition: D
            ) => string | object | Promise<string> | Promise<object>
        ) => { ⋯ }
    }
    

    where TupleToIsect<T> turns a tuple to an intersection of its elements (which is definitely possible in TYpeScript).

    But there are no higher-kinded types so this is not directly doable.


    You can indirectly represent higher-kinded types, but all the ways I know of to do this are clunky and not something I'd necessarily recommend, since they usually require overhead to maintain. I'll present one possibility now, but do keep in mind that it's just for demonstration, and there are other approaches. Indeed, microsoft/TypeScript#1213 mentions several such approaches.

    I'm going to say that there is a global registry of higher kinded types, an interface like

    interface HKT<T = any> {}
    

    Then whenever you want to declare some type is higher-kinded, you have to add it to the registry via declaration merging:

    interface HKT<T> { auth: User<T extends AuthOptions ? T : never> }
    

    or

    interface HKT<T> { validation: ValidationResult<T extends ValidationOptions ? T : never> }
    

    Later, when you want to pass a higher-kinded type around, you refer to it by key like "auth" or "validation", and to apply it like F<T>, you can use

    type Apply<H extends keyof HKT, T> = HKT<T>[H];
    

    like Apply<"auth", { roles: ["customer", "user"] }>, which maps to User<{roles: ["customer", "user"]>.

    Now you can try to define plugin functions like

    type Plugin<H extends keyof HKT, C> =
        (<R extends C>(route: R, req: Request) => Apply<H, R>);
    

    But it's very unlikely that TypeScript will ever be able to infer H and C from a value of type Plugin<H, C>. So we can do more hand-holding, and say that we want a plugin function type to have an H property of type H and a C property of type C, so that TypeScript can just read it off the type of the function, like

    type Plugin<H extends keyof HKT, C> =
        { H: H, C: C } & (<R extends C>(route: R, req: Request) => Apply<H, R>);
    

    Which means we have more registration work to do:

    auth.H = "auth" as const;
    auth.C = null! as AuthOptions;
    validation.H = "validation" as const;
    validation.C = null! as ValidationOptions;
    

    And now, finally, we can defined createServer like

    declare function createServer<F extends Plugin<any, any>[]>(...plugins: F): {
        route: <const D extends TupleToIsect<{ [I in keyof F]: F[I]["C"] }>>(
            definition: D,
            handler: (
                context: TupleToIsect<{ [I in keyof F]: Apply<F[I]["H"], D> }>,
                definition: D
            ) => string | object | Promise<string> | Promise<object>
        ) => { ⋯ };
    }
    

    And if we call it, you get the behavior you wanted:

    server.route({
        roles: ['customer', 'user'],
        headers: { 'x-string-header': 'string', 'x-number-header': 'number' },
    }, (context, definition) => {
        context.user.role === 'public' // error
        return context.headers['x-string-header'] // okay
    })
    

    So you can do this, but at the cost of needing to take your plugins and augment them so that TypeScript can simulate higher kinded types. As I said, there are other approaches, and maybe you would find one of them palatable for your use case, but I just don't know any to recommend, because this is really at or past the edge of what the TypeScript type system can do well.


    It's possible that at some point in the future, TypeScript will implement higher kinded types. Or maybe some other higher-order typing will appear that serves this purpose. Like an extension to the support added in TypeScript 3.4 for higher order type inference from generic functions (It's not useful for you now because the support is quite limited. It would definitely not work with a variadic plugins input, nor can you use it to put the resulting generic functions inside object properties, so your use case as stated is outside the scope of the support. Even if you tried to fix that, I think the fact that you want a single generic type parameter D to apply to all the plugin type functions, means it's outside the scope). Or maybe TypeScript will get call types as requested in microsoft/TypeScript#40179. Call types are related to higher kinded types but they are not the same. I won't bother trying to go through the exercise of simulating these other possibilities either, since it would also be a lot of overhead and probably isn't particularly illuminating.

    Suffice it to say that currently, TypeScript isn't really equipped to handle the sort of type juggling you're trying to do here, and you will have to settle either for approximations, or simulations of varying levels of complexity.


    Playground link to code