Alex Wayne
Alex Wayne

Reputation: 186994

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] }),
  )
}

Upvotes: 1

Views: 1853

Answers (2)

skink
skink

Reputation: 5711

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 }));

Upvotes: 1

Jay McDoniel
Jay McDoniel

Reputation: 70101

Unfortunately, decorators cannot mutate a class definition and there's a lot of issues in the Typescript repo about decorators not being able to modify classesor their definitions. This includes the type definition to my understanding, so there's no immediate way to say that when a method has a decorator that a different type should be the return. The closest you could get, taking your contrived example, would be to rename the Routes['GET /foo'] to a type alias like type FooRoute = Routes['Get /foo'] so that you now only need FooRoute['query'].

// contrived example from original post, modified

import { applyDecorators, RequestMapping, RequestMethod, Controller, Query } from '@nestjs/common'

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

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

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

type FooRoute = Routes['GET /foo']

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

So now there's less repeated code, but it is still repeated in the end.

Upvotes: 0

Related Questions