How NestJS MetadataScanner Works: Prototype Introspection for Route Discovery

The MetadataScanner is a lightweight prototype introspection utility in NestJS that traverses class inheritance chains to identify callable methods, filtering out constructors and accessors while memoizing results to optimize route discovery at application startup.

NestJS relies on the MetadataScanner to dynamically discover controller methods during the bootstrap process. Located in packages/core/metadata-scanner.ts, this utility serves as the foundation for the framework's routing system by extracting method names that can then be inspected for HTTP method decorators like @Get() or @Post().

Core Implementation in metadata-scanner.ts

The MetadataScanner class implements a single public method, getAllMethodNames(), which accepts a prototype object and returns an array of method names as strings.

Prototype Chain Traversal and Caching

The scanner employs a do-while loop to climb the prototype chain using Reflect.getPrototypeOf(), stopping before reaching Object.prototype. It utilizes a private cachedScannedPrototypes Map to store results for each prototype, ensuring subsequent scans return cached arrays in O(1) time.

// packages/core/metadata-scanner.ts
export class MetadataScanner {
  private readonly cachedScannedPrototypes: Map<object, string[]> = new Map();

  public getAllMethodNames(prototype: object | null): string[] {
    if (!prototype) return [];

    if (this.cachedScannedPrototypes.has(prototype)) {
      return this.cachedScannedPrototypes.get(prototype)!;
    }

    const visitedNames = new Map<string, boolean>();
    const result: string[] = [];
    this.cachedScannedPrototypes.set(prototype, result);

    do {
      for (const property of Object.getOwnPropertyNames(prototype)) {
        if (visitedNames.has(property)) continue;
        visitedNames.set(property, true);

        const descriptor = Object.getOwnPropertyDescriptor(prototype, property);
        if (descriptor?.set || descriptor?.get || isConstructor(property) ||
            !isFunction(prototype[property as keyof typeof prototype])) {
          continue;
        }
        result.push(property);
      }
    } while ((prototype = Reflect.getPrototypeOf(prototype)) &&
             prototype !== Object.prototype);

    return result;
  }
}

Filtering Logic for Callable Members

During traversal, the scanner applies strict filters to exclude properties that cannot serve as route handlers. It retrieves property descriptors via Object.getOwnPropertyDescriptor() and skips any member that meets these criteria:

  • Getters or setters (descriptor.get or descriptor.set)
  • Constructor methods (isConstructor(property))
  • Non-function values (!isFunction(prototype[property]))

This ensures only callable method names are returned, preventing the routing system from attempting to register properties or accessor methods as HTTP endpoints.

Integration with the Routing Pipeline

The MetadataScanner operates as a dependency for higher-level explorers that transform method names into executable routes.

PathsExplorer Consumption

The PathsExplorer class in packages/core/router/paths-explorer.ts receives a MetadataScanner instance through its constructor. Its scanForPaths() method invokes getAllMethodNames() on controller prototypes, then iterates through the results to extract NestJS-specific metadata using reflection keys like PATH_METADATA and METHOD_METADATA.

// packages/core/router/paths-explorer.ts
export class PathsExplorer {
  constructor(private readonly metadataScanner: MetadataScanner) {}

  public scanForPaths(instance: Controller, prototype?: object): RouteDefinition[] {
    const instancePrototype = isUndefined(prototype)
      ? Object.getPrototypeOf(instance)
      : prototype;

    return this.metadataScanner
      .getAllMethodNames(instancePrototype)
      .reduce((acc, method) => {
        const route = this.exploreMethodMetadata(instance, instancePrototype, method);
        if (route) acc.push(route);
        return acc;
      }, [] as RouteDefinition[]);
  }
}

RouterExplorer Registration

The resulting RouteDefinition[] array passes to RouterExplorer in packages/core/router/router-explorer.ts, which registers the actual HTTP handlers with the underlying platform adapter. This includes applying versioning constraints, host filters, and middleware guards defined by the controller decorators.

Practical Code Examples

Direct Scanner Usage

You can instantiate MetadataScanner directly to inspect any class prototype:

import { MetadataScanner } from '@nestjs/core/metadata-scanner';

class SampleService {
  foo() {}
  get bar() { return 1; }
  baz() {}
}

const scanner = new MetadataScanner();
const methods = scanner.getAllMethodNames(SampleService.prototype);
// methods => ['foo', 'baz']

The output excludes the getter bar and the constructor, returning only callable methods.

Inspecting Controller Routes

Use PathsExplorer to extract route definitions from a controller instance:

import { Controller, Get } from '@nestjs/common';
import { PathsExplorer } from '@nestjs/core/router/paths-explorer';
import { MetadataScanner } from '@nestjs/core/metadata-scanner';

@Controller('cats')
class CatsController {
  @Get()
  findAll() {}

  @Get(':id')
  findOne() {}
}

const scanner = new MetadataScanner();
const explorer = new PathsExplorer(scanner);
const routes = explorer.scanForPaths(new CatsController());

// routes => [
//   { path: ['/cats'], requestMethod: RequestMethod.GET, ... },
//   { path: ['/cats/:id'], requestMethod: RequestMethod.GET, ... }
// ]

Custom Metadata Discovery

The scanner supports custom decorator discovery for plugin development:

import { MetadataScanner } from '@nestjs/core/metadata-scanner';
import 'reflect-metadata';

const MY_META = Symbol('my_meta');

function MyDecorator() {
  return (target: any, key: string) => {
    Reflect.defineMetadata(MY_META, true, target, key);
  };
}

class Demo {
  @MyDecorator()
  hello() {}

  world() {}
}

const scanner = new MetadataScanner();
const methods = scanner.getAllMethodNames(Demo.prototype);
const annotated = methods.filter(m => Reflect.getMetadata(MY_META, Demo.prototype, m));
console.log(annotated); // ['hello']

Performance Characteristics

The memoization strategy in cachedScannedPrototypes ensures that each prototype is scanned exactly once per application lifecycle. This prevents redundant reflection operations as the dependency injection container resolves controller instances, making the bootstrap process efficient even for large applications with complex inheritance hierarchies.

Summary

  • MetadataScanner in packages/core/metadata-scanner.ts walks class prototype chains to discover callable method names while filtering out constructors and accessors.
  • It caches results in a Map to provide O(1) lookups for repeated scans of the same prototype.
  • PathsExplorer consumes the scanner in packages/core/router/paths-explorer.ts to extract route metadata and produce RouteDefinition objects.
  • The scanner supports custom metadata discovery, making it useful for plugin authors who need to introspect class methods beyond standard HTTP routing.

Frequently Asked Questions

What does MetadataScanner exclude from method discovery?

The scanner excludes the class constructor, any properties with getters or setters, and non-function values. It only returns property names that are callable methods, ensuring that the routing system does not attempt to invoke properties or accessor methods as HTTP handlers.

Where is MetadataScanner used in the NestJS codebase?

Beyond HTTP routing in PathsExplorer and RouterExplorer, the scanner is also utilized by GatewayMetadataExplorer in the WebSockets package (packages/websockets/gateway-metadata-explorer.ts) and within the testing utilities (packages/testing/testing-module.builder.ts) to discover lifecycle hooks and provider methods.

Can I use MetadataScanner for custom decorators outside of HTTP controllers?

Yes. Since the scanner only requires a prototype object and returns method names as strings, you can use it with any class to identify methods decorated with custom metadata. Combine it with Reflect.getMetadata() to filter for your specific decorator keys, as shown in the custom discovery example above.

How does the caching mechanism improve performance?

The cachedScannedPrototypes Map stores scanned method name arrays against their prototype objects. When the same prototype is scanned again—common during module initialization or testing—the method returns the cached array immediately instead of re-traversing the prototype chain and re-evaluating property descriptors.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →