Files
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
Mapfrom 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.
@Injectable()marks a class as one the container may build, by defining metadata on it.resolve(target)answers an instance oftarget, building it the first time and answering the same instance every time after, per container.- To build it, read
design:paramtypes(absent when there are no parameters), resolve each parameter type, and callnew target(...args). - A class that is not injectable is refused with
Error("<Class> is not injectable"); a parameter whose type is not injectable withError("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 nodesign: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, sinceStoredoes not exist at runtime. Report the type the metadata holds,Object.
Remember
- Metadata is data attached to a class at runtime:
Reflect.defineMetadataandReflect.getMetadata. emitDecoratorMetadatarecords a decorated class's constructor parameter types indesign: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.
Press Run tests to start the app. Its log appears here.Tests
- container.spec.tsrun to see its tests