Metadata and injection — TypeScript · Foundations

How NestJS knows what to pass to your constructor: the compiler records each decorated class's parameter types as metadata, and a container reads them to build every class with its dependencies. Build that container.

What you will learn

Read the theory for Metadata and injection

All TypeScript lessons

All Foundations courses

loading types…

What you'll learn

  • Define and read metadata on a class with reflect-metadata
  • Explain what emitDecoratorMetadata records in design:paramtypes, and why an interface appears there as Object
  • Build a dependency injection container that resolves constructor parameters recursively and shares one instance per class

Metadata and injection

Write constructor(private readonly catsService: CatsService) {} in a Nest controller, and when the app starts, a CatsService is there, built once and shared with everything else that asked for one. Nobody called new. This last lesson takes that apart. It rests on two things this course has already met, decorators and classes, and one it has not: metadata, facts stored on a class that code can read at runtime. At the end you will have built the core of what Nest does at boot, in about twenty lines.

Metadata on a class

The reflect-metadata package adds a small API to the global Reflect object for attaching facts to a class (or to a property of it) and reading them back:

import 'reflect-metadata';

Reflect.defineMetadata('role', 'admin', AdminGuard);   // write: key, value, target
Reflect.getMetadata('role', AdminGuard);               // read: 'admin'
Reflect.getMetadata('role', OtherGuard);               // undefined: never written

The key can be any string or symbol; the value anything. This is where Nest's decorators write: @Controller('cats') defines a path metadata of 'cats' on the class, and at boot the router reads it. A decorator's job is usually one defineMetadata call.

What the compiler records for you

With the emitDecoratorMetadata compiler option on, as it is in every Nest project and in this editor, TypeScript writes some metadata of its own on every class that has a decorator. The one that matters is design:paramtypes: the constructor's parameter types, as the actual classes.

@Injectable()
class MailService {
  constructor(transport: SmtpTransport, templates: TemplateStore) {}
}

Reflect.getMetadata('design:paramtypes', MailService); // [SmtpTransport, TemplateStore]

This is how types, which are otherwise erased, survive into the running program: the compiler copies the constructor's parameter types into a runtime array. It only does so for a class with at least one decorator, which is one reason @Injectable() exists; and a class whose constructor takes no parameters gets no such array.

Two limits follow from how it works. An interface has no runtime existence, so a parameter typed with one is recorded as plain Object, which tells a container nothing; Nest makes you supply a token with @Inject('STORE') for exactly that case. And the array is built when the class is defined, so a parameter's class must already exist: the reason Fundamentals has a lesson on circular dependencies.

A container

Dependency injection means a class receives what it needs instead of building it. A container is the thing that builds everything else: asked for a class, it reads the class's parameter types, builds each of those the same way, first, and passes them to new. Because each dependency is resolved by the same function, a whole graph of classes is built from one call.

Two more rules make it Nest-like:

  • One instance per class. The first request builds it; every later one, from anywhere, gets the same object. A logger shared by a repository and a service is one logger. Keep a Map from class to instance, and look there first.
  • Refuse clearly. A class nobody marked as injectable is refused, and so is a parameter whose type the container cannot build, naming the class, the position and the type. Nest's own version of that message, Nest can't resolve dependencies of the CatsService (?, Logger), is one of the most common errors a Nest developer meets, and after this lesson you will know exactly what it is saying.
const INJECTABLE = Symbol('injectable');
function Singleton(): ClassDecorator {
  return (target) => Reflect.defineMetadata(INJECTABLE, true, target);
}

Your task

The work is in container.ts; the spec defines a small graph of services and asks your container for them.

  1. @Injectable() marks a class as one the container may build, by defining metadata on it.
  2. resolve(target) answers an instance of target, building it the first time and answering the same instance every time after, per container.
  3. To build it, read design:paramtypes (absent when there are no parameters), resolve each parameter type, and call new target(...args).
  4. A class that is not injectable is refused with Error("<Class> is not injectable"); a parameter whose type is not injectable with Error("Cannot resolve <Class>: parameter <index> (<Type>) is not injectable").

When it fails

  • "the tests could not run: not written yet": @Injectable() runs when the spec defines its classes, before any test.
  • Cannot read properties of undefined (reading 'map'): a class without constructor parameters has no design:paramtypes. Default to [].
  • "builds each class once" fails with two loggers: the instance is created but not stored, or stored after its dependencies were resolved a second time.
  • The interface case says parameter 1 (Store): it cannot, since Store does not exist at runtime. Report the type the metadata holds, Object.

Remember

  • Metadata is data attached to a class at runtime: Reflect.defineMetadata and Reflect.getMetadata.
  • emitDecoratorMetadata records a decorated class's constructor parameter types in design:paramtypes.
  • Interfaces are recorded as Object; that is why Nest needs tokens for them.
  • A container resolves parameters recursively and keeps one instance per class.
Stuck? Show a hint

Reflect.defineMetadata(key, value, target) writes, Reflect.getMetadata(key, target) reads. Reflect.getMetadata('design:paramtypes', target) is an array of the constructor's parameter types, or undefined when it has none. Keep a Map from class to instance: answer from it when it has the class; otherwise check the class, resolve each parameter type (checking it too), call new target(...args), and store the result before returning it.