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