Metadata & Reflection
Decorator metadata lets your code carry type information from compile time into the runtime, where decorators and dependency-injection containers can read it. This capability belongs almost entirely to the legacy decorator system: with experimentalDecorators and emitDecoratorMetadata enabled, the TypeScript compiler emits the runtime types of decorated declarations, and the reflect-metadata polyfill exposes a Reflect API to read them. NestJS, TypeORM, Angular, and class-validator all depend on this pipeline. This page explains the metadata keys the compiler emits, how reflect-metadata works, and the newer Stage 3 context.metadata alternative.
Why reflect-metadata
TypeScript types are erased during compilation, so by default nothing knows that a parameter was a Logger or a field was a string at runtime. The reflect-metadata package adds a standardized metadata store on objects plus a Reflect API, and emitDecoratorMetadata tells the compiler to populate it for anything that has a decorator.
npm install reflect-metadata
// Import ONCE, before any decorated class is loaded (e.g. main.ts):
import "reflect-metadata";
// tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
Without emitDecoratorMetadata, the design:* keys below are never written, even if reflect-metadata is imported. Both halves are required.
The emitted design keys
When a declaration has at least one decorator, the compiler emits up to three well-known metadata keys describing its runtime types. The values are constructor references (e.g. String, Number, your classes), with Object used for types it cannot represent.
| Key | Applies to | Value |
|---|---|---|
design:type | property / accessor | The constructor of the member’s type |
design:paramtypes | constructor / method | Array of parameter type constructors |
design:returntype | method | The return type’s constructor |
import "reflect-metadata";
function Capture(target: object, key: string) {
const t = Reflect.getMetadata("design:type", target, key);
console.log(`${key}: ${t.name}`);
}
class Product {
@Capture name!: string; // logs "name: String"
@Capture price!: number; // logs "price: Number"
}
The decorator triggers emission, and Reflect.getMetadata("design:type", ...) reads back the constructor. Note a decorator must be present for the metadata to exist at all.
Reading constructor parameter types
design:paramtypes is the backbone of dependency injection. It captures the constructor’s parameter types so a container can resolve and inject each dependency.
import "reflect-metadata";
class Mailer {}
class Db {}
function Service<T extends new (...a: any[]) => any>(ctor: T) {
return ctor;
}
@Service
class OrderService {
constructor(private mailer: Mailer, private db: Db) {}
}
const params = Reflect.getMetadata("design:paramtypes", OrderService);
// → [Mailer, Db]
A container maps each entry to a registered provider and calls new OrderService(...) with the resolved instances. This is exactly how NestJS wires constructors — see Decorators in NestJS.
Defining and reading custom metadata
Beyond the design:* keys, you can attach your own metadata with Reflect.defineMetadata and retrieve it with Reflect.getMetadata. This is how @Roles("admin") or route metadata is stored.
import "reflect-metadata";
const ROLES = Symbol("roles");
function Roles(...roles: string[]) {
return (target: object, key: string) =>
Reflect.defineMetadata(ROLES, roles, target, key);
}
class AdminController {
@Roles("admin", "owner") deleteUser() {}
}
const guard = Reflect.getMetadata(ROLES, AdminController.prototype, "deleteUser");
// → ["admin", "owner"]
A guard or interceptor reads ROLES before running the handler and rejects unauthorized callers. Custom metadata works the same whether or not emitDecoratorMetadata is on.
Interfaces and union/literal types are erased to
Objectindesign:*keys. That is why DI frameworks require a class or an explicit token (@Inject("TOKEN")) — an interface alone carries no runtime type.
The Stage 3 alternative: context.metadata
Stage 3 decorators do not emit design:* data, but each decorator’s context exposes a shared metadata object stored at Symbol.metadata on the class. It lets decorators communicate without reflect-metadata.
function tag(label: string) {
return (_v: unknown, context: ClassMethodDecoratorContext) => {
(context.metadata as any)[context.name] = label;
};
}
class Api {
@tag("GET") list() {}
}
const meta = (Api as any)[Symbol.metadata];
// → { list: "GET" }
This is type information you write explicitly, not types inferred by the compiler — Stage 3 has no emitDecoratorMetadata equivalent yet, so framework-grade DI still relies on the legacy pipeline.
Best Practices
- Import
reflect-metadataexactly once, at the very top of your entry file, before decorated classes load. - Enable both
experimentalDecoratorsandemitDecoratorMetadata, ordesign:*keys will be missing. - Use classes (not interfaces) for injectable types so
design:paramtypescarries real constructors. - Reach for
@Inject(TOKEN)whenever a dependency is an interface or primitive that erases toObject. - Key custom metadata with a
Symbolto avoid collisions with other libraries. - Prefer Stage 3
context.metadatafor new, framework-free code that just needs decorator coordination. - Remember metadata emission requires at least one decorator on the declaration.