Search code examples
typescriptnestjsdecorator

Use one type in multiple parts of a Nest.js controller method


I have a system of sharing types between the backend in frontend which looks something like this in the controller:

@Controller()
export class MyController {
  
  @ApplyRoute('GET /foo')
  async findAll(
    @Query() { name }: Routes['GET /foo']['query'], // repeated route name
  ): Promise<Routes['GET /foo']['response']> { // repeated route name
    return { foo: true }
  }
}

Note how 'GET /foo' appears 3 times: once to declare the http method/path, once to get the type of query parameters, and once to enforce the return type.

See playground

I'm looking for a way to somehow remove that repetition, but I can't seem to think of any way that plays nice with the decorators. Right now someone could use the path of one route and then use a different route's query or response, which is probably quite wrong.

How can this duplication of route names be removed?


For example, something that does what this totally invalid syntax implies:

@Controller()
export class MyController {
  
  @ApplyRoute('GET /foo') {
    async findAll(
      @Query() { name }: CurrentRoute['query'],
    ): Promise<CurrentRoute['response']> {
      return { foo: true }
    }
  }
}

For additional info, the plumbing works like this.

There is a type that defines all routes, and the types that route accepts and returns:

type Routes = {
  'GET /foo': {
    query: { name: string }
    response: { foo: true }
  }

  'GET /bar': {
    query: {},
    response: { bar: true }
  }
}

Then we have a little helper decorator which parses the route name to the @RequestMapping() decorator to set the http method and path for a controller method:

export function ApplyRoute(routeName: keyof Routes): MethodDecorator {
  const [method, path] = routeName.split(' ') as ['GET' | 'POST', string]
  return applyDecorators(
    RequestMapping({ path, method: RequestMethod[method] }),
  )
}

Solution

  • It's possible to use the dynamic routing techniques to remove the code duplication. Consider this example:

    // define a controller that will host all methods of `Routes`
    // it's, of course, also possible to have many of these or even have them generated based on the `Routes` descriptor
    @Controller()
    export class MyController {}
    
    function Action<T extends keyof Routes>(route: T, fn: (query: Routes[T]["query"]) => Routes[T]["response"]): void {
      const [method, path] = route.split(" ") as ["GET" | "POST", string];
      const key = path.split("/").join("_");
    
      // assigning the function body to a method in the controller class
      MyController.prototype[key] = fn;
    
      // applying the `Query` directive to the first parameter
      // this could also be configured through the `Routes` in case if you have e.g. post methods
      Query()(MyController.prototype, key, 0);
    
      // applying the `Get` decorator to the controller method
      RequestMapping({ path: path, method: RequestMethod[method] })(
        MyController,
        key,
        Object.getOwnPropertyDescriptor(MyController.prototype, key)
      );
    }
    
    // now, register the methods in the global scope
    Action("GET /foo", ({ name: string }) => ({ foo: true }));
    Action("GET /bar", () => ({ bar: true }));