elysiajs / elysia

Ergonomic Framework for Humans
https://elysiajs.com
MIT License
9.74k stars 207 forks source link

Elysia group router plugin type-safety #79

Closed itsyoboieltr closed 1 week ago

itsyoboieltr commented 1 year ago

Hello! I recently released a plugin called elysia-group-router which aims to address a concern a lot of people seemed to express about this framework: the separation of routes (+ file-system routing). I came up with a very simplistic solution, which is a group and folder based file-system router. Each group will be prefixed based on the containing folder's name, and will be created by an index.ts file. In Javascript land, this works great, and the code is pretty simple. However, in Typescript, due to the dynamic nature of imports (you could have any number of groups, with any name under the root), type inference seems impossible this way (also, method chaining does not seem possible in this case).

The code that introduces routing, but without any type inference at all (which is the current state of the project):

// file: groupRouter.ts
import type { Elysia } from 'elysia';
import path from 'node:path';
import { glob } from 'glob';

export default async function groupRouter(app: Elysia, groupsDir = 'groups') {
  const groupsPath = path.join(path.dirname(Bun.main), groupsDir);
  const files = await glob('**/index.ts', { cwd: groupsPath, absolute: true });
  for (const file of files) {
    const { default: group } = await import(file);
    const folderName = path.dirname(file).replace(groupsPath, '');
    app.use((app) => group(app, folderName));
  }
  return app;
}

I tried to make an example of what an experimental implementation could be with proper type support, by generating the types in runtime:

// file: groupRouter.ts
import type { Elysia } from 'elysia';
import path from 'node:path';
import fs from 'node:fs';
import { glob } from 'glob';
import type { ElysiaGroupRouter } from './groups/ElysiaGroupRouter';

function generateTypeDefinition(files: string[]): void {
  const relativePaths = files.map((file) =>
    path.relative('src/groups', file).replace('/index.ts', '')
  );

  // Generate importNames based on relativePaths
  const importNames = relativePaths.map((relativePath) => {
    return (
      relativePath
        .split('/')
        .map((pathSegment, index) =>
          index === 0
            ? pathSegment
            : pathSegment.charAt(0).toUpperCase() + pathSegment.slice(1)
        )
        .join('') + 'Group'
    );
  });

  let output = '';

  // Generate the import statements
  for (let i = 0; i < relativePaths.length; i++) {
    output += `import ${importNames[i]} from './${relativePaths[i]}';\n`;
  }

  // Generate the type
  output += `export type ElysiaGroupRouter = `;
  for (let i = 0; i < importNames.length; i++) {
    if (i > 0) {
      output += ' & ';
    }
    output += `ReturnType<typeof ${importNames[i]}<'/${relativePaths[i]}'>>`;
  }
  output += ';\n';

  // Write the output to the declaration file
  fs.writeFileSync('src/groups/ElysiaGroupRouter.d.ts', output);
}

export default async function groupRouter(app: Elysia, groupsDir = 'groups') {
  const groupsPath = path.join(path.dirname(Bun.main), groupsDir);
  const files = await glob('**/index.ts', { cwd: groupsPath, absolute: true });

  generateTypeDefinition(files);

  for (const file of files) {
    const { default: group } = await import(file);
    const folderName = path.dirname(file).replace(groupsPath, '');
    app.use((app) => group(app, folderName));
  }

  return app as typeof app & ElysiaGroupRouter;
}

The content of the groups folder, looks like the following:

// file: groups/hello/index.ts
import type { Elysia } from 'elysia';
export default function helloGroup<const T extends string>(
  app: Elysia,
  prefix: T
) {
  return app.group(prefix, (app) => app.get('', () => 'hello'));
}
// file: groups/test/index.ts
import type { Elysia } from 'elysia';
export default function testGroup<const T extends string>(
  app: Elysia,
  prefix: T
) {
  return app.group(prefix, (app) => app.get('', () => 'test'));
}
// file: groups/hello/test/index.ts
import type { Elysia } from 'elysia';
export default function helloTestGroup<const T extends string>(
  app: Elysia,
  prefix: T
) {
  return app.group(prefix, (app) => app.get('', () => 'helloTest'));
}

Using the experimental group router code mentioned above, the following output can be generated:

// file: groups/ElysiaGroupRouter.d.ts
import helloGroup from './hello';
import testGroup from './test';
import helloTestGroup from './hello/test';
export type ElysiaGroupRouter = ReturnType<typeof helloGroup<'/hello'>> & ReturnType<typeof testGroup<'/test'>> & ReturnType<typeof helloTestGroup<'/hello/test'>>;

In theory, this should work (according to my understanding of types). However, in practice, this throws the following error Type instantiation is excessively deep and possibly infinite.ts(2589). I wonder how could this be fixed, and how could proper type support be added to enable type-safe file-system routing with Elysia.

I am posting this issue with the hope that we have a Typescript wizard among us who has some idea how to tackle this task to make the plugin work with types.

itsyoboieltr commented 1 year ago

Some progress with the types with the new 0.6 plugin system, also a lot of the boilerplate is removed.

import { Elysia } from 'elysia';

const test = <const T extends string>(prefix: T) =>
  new Elysia({ prefix }).get('/', () => prefix);

declare const use: Elysia['use'];

type testGroupReturnType = ReturnType<typeof test<'/test'>>;

type ElysiaTypedRoute = ReturnType<typeof use<testGroupReturnType>>;

I guess if we could get the type returned from use, then nested use calls could be executed using the types of all routes using type parameters to return the correct type. However, I cannot get the type even of a single use call.

Error message of ElysiaTypedRoute:

Type 'Elysia<"/test", { request: {}; store: {}; schema: {}; error: {}; meta: { defs: {}; exposed: {}; schema: { "/test/": { get: { body: unknown; headers: undefined; query: undefined; params: undefined; response: { '200': "/test"; }; }; }; }; }; }>' does not satisfy the constraint 'ElysiaInstance'.ts(2344)
Type 'Elysia<"/test", { request: {}; store: {}; schema: {}; error: {}; meta: { defs: {}; exposed: {}; schema: { "/test/": { get: { body: unknown; headers: undefined; query: undefined; params: undefined; response: { '200': "/test"; }; }; }; }; }; }>' does not satisfy the constraint 'ElysiaInstance'.
Property 'request' is missing in type 'Elysia<"/test", { request: {}; store: {}; schema: {}; error: {}; meta: { defs: {}; exposed: {}; schema: { "/test/": { get: { body: unknown; headers: undefined; query: undefined; params: undefined; response: { '200': "/test"; }; }; }; }; }; }>' but required in type 'ElysiaInstance'.ts(2344)

Also tried the following, since something in the error message is about 'ElysiaInstance':

import { Elysia } from 'elysia';

const test = <const T extends string>(prefix: T) =>
  new Elysia({ prefix }).get('/', () => prefix);

declare const use: Elysia['use'];

type testGroupReturnType = ReturnType<typeof test<'/test'>>;

type ElysiaTypedRoute = ReturnType<typeof use<ElysiaInstance, testGroupReturnType>>;

Error message of ElysiaTypedRoute:

Type 'Elysia<"/test", { request: {}; store: {}; schema: {}; error: {}; meta: { defs: {}; exposed: {}; schema: { "/test/": { get: { body: unknown; headers: undefined; query: undefined; params: undefined; response: { '200': "/test"; }; }; }; }; }; }>' does not satisfy the constraint 'Elysia<"", { store: {}; request: {}; schema: {}; error: {}; meta: { schema: {}; defs: {}; exposed: {}; }; }>'.
  Types of property 'config' are incompatible.
    Type 'ElysiaConfig<"/test">' is not assignable to type 'ElysiaConfig<"">'.
      Type '"/test"' is not assignable to type '""'.ts(2344)

I am not sure what the type error is about, because this should simulate the exact situation on the type level as if one was to pass in straight a function, which works with correct types. To me, personally, it feels like something is still missing from ElysiaInstance, however, I am not sure how the typing behind the library really works, maybe some type argument needs to be passed there too, to make it compatible.

I have this intuition, since an unchanged Elysia instance's type inference does work:

type ElysiaTypedRoute = ReturnType<typeof use<ElysiaInstance, Elysia>>; // this works!

Any comments maybe? @SaltyAom

Codinak commented 1 year ago

Maybe the issue is of nature similar to the following I've spotted:

You can break inference or make it less efficient if you "break" the chain with proposed plugin examples:

const plugin = (app: Elysia) => app.get("/foo", () => "bar")

In this example, the type instance that consumed the plugin has to extract type data from the instance returned.

If we had inferrence:

const plugin = <Instance extends Elysia>(app: Instance) => app.get("/foo", () => "bar")

We actually infer the specific Instance and alter it. It will probably need less code. And if no extra code was written in similar cases, it might break chain of type alteration and TS will think you try to assign the blank Instance to a specific, altered one.

To make it clear: I see that you use instantiation here via new Elysia({prefix: "xyz"}). What I want to say is, that if you have somewhere down the line types defined the above way (generic vs. non generic) that it might cause the errors you see, where mapped type does not fit ElysiaInstance, the fact that the type name did not change is its probably default on one side (unmapped)

itsyoboieltr commented 11 months ago

@SaltyAom with the 0.7 rewriting of types, does Elysia provide any type-level utilities to merge the types of Elysia instances? It could possibly allow for a type-safe filesystem router if we could do the following:

import { Elysia } from 'elysia';

const test = new Elysia({ prefix: 'test' }).get('', () => 'test');

const test2 = new Elysia({ prefix: 'test2' }).get('', () => 'test2');

type App = typeof test & typeof test2; // or something else to merge multiple Elysia instance types

I personally think it would be important to tackle the issue of type-safe filesystem routing, because a lot of people are looking for a way to structure their projects. This could lead to a viable solution.

SaltyAom commented 11 months ago

I'm not aware of merging Elysia instance without using use, but technically if you want to merge routes, then yes, you can just use typeof and &

import { Elysia, t } from 'elysia'

const app1 = new Elysia()
    .get('/a', () => 'a')
    .listen(3000)

const app2 = new Elysia()
    .get('/b', () => 'b')
    .listen(3000)

export type App = typeof app1 & typeof app2

type Routes = keyof App['schema'] // return /a | /b
itsyoboieltr commented 11 months ago

That is great! Your example works, but my example does not, because I used a prefix for the Elysia instances. When used with prefix, the type of app is Never.

Reproduction:

import { Elysia } from 'elysia';

const app1 = new Elysia({ prefix: '/a' }).get('', () => 'a');

const app2 = new Elysia({ prefix: '/b' }).get('', () => 'b');

export type App = typeof app1 & typeof app2; // never

type Routes = keyof App['schema']; // string | number | symbol

Is there any way to solve this issue? @SaltyAom

itsyoboieltr commented 8 months ago

I realized I can solve it (with a little bit of a hack) by working-around the issue the following way: instead of merging instances with prefixes, I merge the instances without prefixes, and manually add the prefixes to the routes myself.

In practice, it looks like this: elysia-filesystem-router

Excerpt:

import type Elysia from 'elysia';
import { helloRoute } from './groups/hello';
import { helloTestRoute } from './groups/hello/test';
import { testRoute } from './groups/test';

declare global {
  type AddPrefixToRoutes<Routes, Prefix extends string> = {
    [K in keyof Routes as `${Prefix}${K & string}`]: Routes[K];
  };

  type RemapElysiaWithPrefix<
    ElysiaType,
    Prefix extends string
  > = ElysiaType extends Elysia<
    infer BasePath,
    infer Decorators,
    infer Definitions,
    infer ParentSchema,
    infer Macro,
    infer Routes,
    infer Scoped
  >
    ? Elysia<
        BasePath,
        Decorators,
        Definitions,
        ParentSchema,
        Macro,
        AddPrefixToRoutes<Routes, Prefix>,
        Scoped
      >
    : never;

  type ElysiaFileSystemRouter = RemapElysiaWithPrefix<
    typeof helloRoute,
    '/hello'
  > &
    RemapElysiaWithPrefix<typeof helloTestRoute, '/hello/test'> &
    RemapElysiaWithPrefix<typeof testRoute, '/test'>;
}

I could not believe it that it worked, but this actually ended up providing a type-safe filesystem routing experience!

The issue is pretty much solved, but because this feels like a dirty hack around a limitation that still exists, which is merging Elysia type declarations with prefixes on the type level, I personally would not encourage closing this issue, until there is a better (and less hackier) solution to make this work, ideally with the Elysia instances also having prefixes, instead of manually monkey-patching the routes.

SaltyAom commented 1 week ago

Elysia main point to reduce "magic" behind the scene as much as possible which includes code generation.

I'd recommended using a 3rd party plugin like Elysia Autoload instead.

Thanks for your understanding.