Skip to content
TypeScript ts decorators 4 min read

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.

KeyApplies toValue
design:typeproperty / accessorThe constructor of the member’s type
design:paramtypesconstructor / methodArray of parameter type constructors
design:returntypemethodThe 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 Object in design:* 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-metadata exactly once, at the very top of your entry file, before decorated classes load.
  • Enable both experimentalDecorators and emitDecoratorMetadata, or design:* keys will be missing.
  • Use classes (not interfaces) for injectable types so design:paramtypes carries real constructors.
  • Reach for @Inject(TOKEN) whenever a dependency is an interface or primitive that erases to Object.
  • Key custom metadata with a Symbol to avoid collisions with other libraries.
  • Prefer Stage 3 context.metadata for new, framework-free code that just needs decorator coordination.
  • Remember metadata emission requires at least one decorator on the declaration.
Last updated June 29, 2026
Was this helpful?