Reflect-metadata is the secret sauce that lets TypeScript’s decorators inspect and modify class metadata at runtime, enabling powerful dependency injection frameworks.
Let’s see it in action. Imagine we have a simple Logger class and a UserService that needs a Logger.
// logger.ts
export class Logger {
log(message: string) {
console.log(`[LOG] ${message}`);
}
}
// user.service.ts
import { Logger } from './logger';
export class UserService {
constructor(private logger: Logger) {} // Dependency declared here
getUsers() {
this.logger.log('Fetching users...');
return ['Alice', 'Bob'];
}
}
Now, how do we tell our DI container to provide an instance of Logger when UserService requests it? This is where reflect-metadata and decorators come in. We’ll use a hypothetical DI container for illustration, but the principle holds.
First, we need to enable experimental decorators and emit metadata in tsconfig.json:
{
"compilerOptions": {
"target": "ES2016",
"module": "CommonJS",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strict": true,
"esModuleInterop": true
}
}
The experimentalDecorators flag allows us to use the @decorator syntax. emitDecoratorMetadata is the key: it instructs the TypeScript compiler to generate metadata about the types used in decorators, which reflect-metadata can then read at runtime.
Here’s how we might define a simple container and register our services:
// container.ts
import 'reflect-metadata'; // Crucial: import this *before* using decorators
class Container {
private services: Map<any, any> = new Map();
private instances: Map<any, any> = new Map();
register<T>(token: new (...args: any[]) => T, constructor: new (...args: any[]) => T) {
this.services.set(token, constructor);
}
get<T>(token: new (...args: any[]) => T): T {
if (this.instances.has(token)) {
return this.instances.get(token);
}
const ServiceConstructor = this.services.get(token);
if (!ServiceConstructor) {
throw new Error(`Service not registered for token: ${token.name}`);
}
// --- This is where reflect-metadata shines ---
const dependencies: any[] = Reflect.getMetadata('design:paramtypes', ServiceConstructor) || [];
const resolvedDependencies = dependencies.map(dep => this.get(dep)); // Recursively resolve dependencies
const instance = new ServiceConstructor(...resolvedDependencies);
this.instances.set(token, instance);
return instance;
}
}
export const DIContainer = new Container();
And here’s how we’d use it with our Logger and UserService:
// index.ts
import 'reflect-metadata';
import { Logger } from './logger';
import { UserService } from './user.service';
import { DIContainer } from './container';
// Register the services
DIContainer.register(Logger, Logger);
DIContainer.register(UserService, UserService);
// Resolve the UserService
const userService = DIContainer.get(UserService);
// Use the service
const users = userService.getUsers();
console.log(users); // Output: [LOG] Fetching users... ["Alice", "Bob"]
When DIContainer.get(UserService) is called, the Reflect.getMetadata('design:paramtypes', ServiceConstructor) line is executed. Because we compiled with emitDecoratorMetadata: true, TypeScript has already embedded information about the types of the constructor parameters for UserService into the class’s metadata. reflect-metadata provides the Reflect.getMetadata API to access this.
Specifically, for UserService, the metadata will tell us that its constructor expects an argument of type Logger. The dependencies.map(dep => this.get(dep)) part then recursively calls this.get(Logger) to resolve that dependency, creates an instance of Logger, and finally passes it to the UserService constructor.
The most surprising thing about emitDecoratorMetadata and reflect-metadata is that the metadata is not automatically available. You must import reflect-metadata at the very top level of your application (or at least before any code that uses Reflect.getMetadata) for it to patch the global Reflect object and enable metadata retrieval. Forgetting this import is the most common reason why Reflect.getMetadata returns undefined or an empty array, leading to "cannot resolve dependency" errors.
This mechanism allows DI frameworks to automatically understand and wire up complex object graphs without explicit configuration for each constructor parameter. You declare your dependencies in the constructor, and the DI container, using metadata, figures out how to provide them.
The next hurdle you’ll likely face is managing different scopes for your dependencies, such as singletons versus transient instances, and how to configure that lifecycle within your DI container.