Decorators & Metadata: Attributes vs. Decorators
For .NET engineers who know: C# attributes,
System.Reflection, customAttributesubclasses, and how ASP.NET Core uses attributes for routing, authorization, and model binding You’ll learn: How TypeScript decorators differ from C# attributes, how NestJS uses them to build an ASP.NET-like framework, how thereflect-metadatalibrary bridges the gap, and why the TC39 Stage 3 decorator spec matters even if you never touch it directly Time: 15-20 minutes
The surface syntax is deceptively similar. [HttpGet("/users")] in C# and @Get('/users') in TypeScript look like the same concept with different brackets. They are not the same. Understanding what actually happens when TypeScript decorators execute — and why — is what separates an engineer who can read NestJS code from one who can write and debug it confidently.
The .NET Way (What You Already Know)
In C#, an attribute is a class that inherits from System.Attribute. When you apply [HttpGet("/users")] to a method, the CLR stores metadata about that method in the assembly’s manifest. Nothing executes at definition time. The attribute instance is not created until something calls GetCustomAttributes() at runtime, typically the ASP.NET Core framework during startup.
// Definition: just a class inheriting Attribute. No magic.
[AttributeUsage(AttributeTargets.Method)]
public class LogExecutionTimeAttribute : Attribute
{
public string Label { get; }
public LogExecutionTimeAttribute(string label = "")
{
Label = label;
}
}
// Application: stores metadata. Nothing runs here.
public class UserService
{
[LogExecutionTime("GetUser")]
public async Task<User> GetUserAsync(int id)
{
return await _repo.FindAsync(id);
}
}
// Consumption: reflection reads the metadata at runtime, usually during startup.
var method = typeof(UserService).GetMethod("GetUserAsync");
var attr = method?.GetCustomAttribute<LogExecutionTimeAttribute>();
if (attr != null)
{
Console.WriteLine($"Label: {attr.Label}"); // "GetUser"
}
The key properties of C# attributes:
- Pure metadata — no code runs when you apply an attribute. The attribute instance is constructed only on demand, by a caller using reflection.
- Type-safe —
AttributeUsagerestricts where they can be applied. The compiler enforces this. - Read-only at runtime — attributes describe the target; they cannot modify it.
- Framework-driven consumption — ASP.NET Core reads attributes during startup to build route tables, authorization policies, and filter pipelines.
This model is clean, predictable, and entirely separate from runtime behavior. The attribute and the code it decorates are independent.
The TypeScript Way
Decorators Are Functions, Not Metadata
TypeScript decorators are functions that execute at class definition time. When the JavaScript engine loads the module containing a decorated class, the decorator functions run immediately. They receive the decorated target as an argument and can — and often do — modify it.
This is the critical difference: applying a decorator is a function call disguised as declarative syntax.
// A class decorator receives the constructor function as its argument.
// It runs when the module is first loaded, before any instance is created.
function LogClass(constructor: Function) {
console.log(`Class defined: ${constructor.name}`);
// You can modify the prototype, wrap the constructor, or do anything else here.
}
@LogClass // This calls LogClass(UserService) at module load time.
class UserService {
getUser(id: number) { /* ... */ }
}
// Console output appears immediately when this module is imported:
// "Class defined: UserService"
TypeScript supports four kinds of decorators, each receiving a different target:
Class decorators receive the constructor:
function Singleton<T extends { new(...args: unknown[]): object }>(constructor: T) {
let instance: InstanceType<T> | null = null;
return class extends constructor {
constructor(...args: unknown[]) {
if (instance) return instance;
super(...args);
instance = this as InstanceType<T>;
}
};
}
@Singleton
class DatabaseConnection {
connect() { /* ... */ }
}
Method decorators receive the prototype, the method name, and the property descriptor — giving full control over the method’s implementation:
function LogExecutionTime(label: string) {
// The outer function is the decorator factory — it returns the actual decorator.
return function (
target: object,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value as (...args: unknown[]) => unknown;
// Replace the method implementation entirely.
descriptor.value = async function (...args: unknown[]) {
const start = performance.now();
const result = await originalMethod.apply(this, args);
const duration = performance.now() - start;
console.log(`[${label}] ${propertyKey}: ${duration.toFixed(2)}ms`);
return result;
};
return descriptor;
};
}
class UserService {
@LogExecutionTime('UserService')
async getUser(id: number): Promise<User> {
return this.repo.findById(id);
}
}
This is different from a C# attribute in a critical way: the LogExecutionTime decorator actually wraps the getUser method. The original implementation is replaced. No reflection needed at call time — the modification is baked in when the class loads.
Property decorators receive the prototype and property name (no descriptor — they cannot directly access the value):
function Required(target: object, propertyKey: string) {
// Convention: store metadata somewhere for later validation use.
const requiredProperties: string[] =
Reflect.getMetadata('required', target) ?? [];
requiredProperties.push(propertyKey);
Reflect.defineMetadata('required', requiredProperties, target);
}
class CreateUserDto {
@Required
name: string = '';
@Required
email: string = '';
age?: number;
}
Parameter decorators receive the prototype, the method name, and the parameter index:
function Body(target: object, methodName: string, parameterIndex: number) {
const existingParams: number[] =
Reflect.getMetadata('body:params', target, methodName) ?? [];
existingParams.push(parameterIndex);
Reflect.defineMetadata('body:params', existingParams, target, methodName);
}
class UserController {
createUser(@Body dto: CreateUserDto): Promise<User> {
return this.userService.create(dto);
}
}
The reflect-metadata Library
You cannot build the C# attribute pattern in TypeScript purely with decorator functions. Decorator functions execute and return — there is no built-in storage for arbitrary metadata. The reflect-metadata package provides that storage: a WeakMap-backed API for associating arbitrary key-value data with classes, methods, and parameters.
npm install reflect-metadata
// Must be imported once at application entry point.
import 'reflect-metadata';
The API mirrors System.Reflection closely (not coincidentally — it was designed for exactly this use case):
// Store metadata
Reflect.defineMetadata(metadataKey, metadataValue, target);
Reflect.defineMetadata(metadataKey, metadataValue, target, propertyKey);
// Read metadata
const value = Reflect.getMetadata(metadataKey, target);
const value = Reflect.getMetadata(metadataKey, target, propertyKey);
// Check existence
const exists = Reflect.hasMetadata(metadataKey, target);
// List all keys
const keys = Reflect.getMetadataKeys(target);
There is one critically useful built-in metadata key: design:type, design:paramtypes, and design:returntype. When emitDecoratorMetadata: true is set in tsconfig.json, the TypeScript compiler emits type information as reflect-metadata entries — giving the runtime access to the TypeScript types that are normally erased.
// tsconfig.json must have:
// "experimentalDecorators": true,
// "emitDecoratorMetadata": true
import 'reflect-metadata';
function Injectable() {
return function (constructor: Function) {
// The TypeScript compiler has emitted the constructor parameter types
// as metadata. We can read them here.
const paramTypes = Reflect.getMetadata('design:paramtypes', constructor);
console.log(paramTypes);
// [UserRepository, LoggerService] — the actual constructor function references
};
}
@Injectable()
class UserService {
constructor(
private readonly userRepo: UserRepository,
private readonly logger: LoggerService,
) {}
}
This is how NestJS’s DI container resolves constructor dependencies without any explicit [FromServices] or registration calls specifying types — the type information is emitted into metadata by the TypeScript compiler and read back at runtime.
Side-by-Side: The Same Pattern in C# and TypeScript
// C# — ASP.NET Core routing and DI
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly IUserService _userService;
// DI container reads IUserService type from constructor,
// resolves it from the service container.
public UsersController(IUserService userService)
{
_userService = userService;
}
[HttpGet("{id:int}")]
[Authorize(Roles = "Admin")]
public async Task<ActionResult<UserDto>> GetUser(
[FromRoute] int id)
{
var user = await _userService.GetUserAsync(id);
return user is null ? NotFound() : Ok(user);
}
}
// TypeScript — NestJS routing and DI
// The structure and intent are nearly identical.
// The mechanism is completely different.
import { Controller, Get, Param, UseGuards, ParseIntPipe } from '@nestjs/common';
@Controller('users') // Stores route prefix in reflect-metadata
export class UsersController {
constructor(
// NestJS reads design:paramtypes metadata to resolve UserService.
private readonly userService: UserService,
) {}
@Get(':id') // Stores route + HTTP method in metadata
@UseGuards(AdminGuard) // Stores guard reference in metadata
async getUser(
@Param('id', ParseIntPipe) id: number, // Stores parameter binding info
): Promise<UserDto> {
return this.userService.getUser(id);
}
}
From the outside, reading NestJS code feels like reading ASP.NET Core code. Under the hood, every @Get(':id') call has already run (at module load time) and stashed metadata in reflect-metadata. The NestJS bootstrap process then reads all that metadata to construct the router table, DI container, and middleware pipeline — exactly what ASP.NET Core does during startup when it calls GetCustomAttributes() across your assemblies.
How NestJS Uses Decorators: The Full Picture
NestJS is built almost entirely on decorators. Understanding the pattern lets you debug it when it breaks and extend it when needed.
Module Decorators and the DI Container
import { Module } from '@nestjs/common';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UserService, UserRepository],
exports: [UserService],
})
export class UsersModule {}
@Module() stores its configuration object in reflect-metadata on the UsersModule class. When NestJS bootstraps, it reads this metadata to construct the module graph — equivalent to IServiceCollection registrations in Program.cs, but driven by metadata rather than imperative calls.
@Injectable() marks a class as eligible for DI resolution and causes design:paramtypes to be read when constructing instances. It is your services.AddScoped<UserService>().
Custom Decorators for Real Use Cases
This is where TypeScript decorators become genuinely powerful. You can compose them to build reusable cross-cutting concerns.
A logging decorator with zero framework dependency:
function Logged(level: 'debug' | 'info' | 'warn' = 'info') {
return function (
target: object,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
const original = descriptor.value as (...args: unknown[]) => Promise<unknown>;
descriptor.value = async function (...args: unknown[]) {
const className = target.constructor.name;
console[level](`${className}.${propertyKey} called`, { args });
try {
const result = await original.apply(this, args);
console[level](`${className}.${propertyKey} returned`, { result });
return result;
} catch (error) {
console.error(`${className}.${propertyKey} threw`, { error });
throw error;
}
};
};
}
class UserService {
@Logged('info')
async createUser(dto: CreateUserDto): Promise<User> {
return this.repo.save(dto);
}
}
A NestJS guard composed into a custom decorator:
In NestJS, you frequently combine multiple decorators into one. This is the equivalent of creating a composite ASP.NET attribute:
import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common';
import { Roles } from './roles.decorator';
import { AuthGuard } from './auth.guard';
import { RolesGuard } from './roles.guard';
// In C#, you'd create a composite attribute or a policy.
// In NestJS, you compose decorators into one with applyDecorators.
export function Auth(...roles: string[]) {
return applyDecorators(
SetMetadata('roles', roles),
UseGuards(AuthGuard, RolesGuard),
);
}
// Usage — reads exactly like a C# composite attribute:
@Controller('admin')
export class AdminController {
@Get('dashboard')
@Auth('admin', 'superadmin') // One decorator, composed behavior
getDashboard() { /* ... */ }
}
A custom parameter decorator:
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
// Equivalent to writing a custom IModelBinder in ASP.NET Core,
// but applied at the parameter level with a decorator.
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user as AuthenticatedUser;
},
);
@Controller('profile')
export class ProfileController {
@Get()
@UseGuards(AuthGuard)
getProfile(@CurrentUser() user: AuthenticatedUser): ProfileDto {
return this.profileService.getProfile(user.id);
}
}
Legacy Decorators vs. TC39 Stage 3 Decorators
This is an area where the JS ecosystem is in active transition, and as a .NET engineer you need to understand why there are two different decorator systems and which one you are using.
The Legacy System: experimentalDecorators
Everything shown so far uses the legacy decorator system, enabled with "experimentalDecorators": true in tsconfig.json. This system was implemented by TypeScript in 2015 based on an early TC39 proposal that was subsequently changed significantly. It is non-standard — it predates the final spec and differs from it in meaningful ways.
NestJS, class-validator, class-transformer, and TypeORM all use the legacy system. It is stable in practice. It will not be removed from TypeScript. But it is not — and never will be — the standard.
The Standard System: TC39 Stage 3
The TC39 decorator proposal reached Stage 3 in 2022 and was finalized. TypeScript 5.0 (released March 2023) shipped support for standard decorators alongside the legacy system. Standard decorators use the same @ syntax but work differently:
| Aspect | Legacy (experimentalDecorators) | Standard (TC39 Stage 3) |
|---|---|---|
tsconfig.json flag | "experimentalDecorators": true | No flag needed (TS 5.0+) |
emitDecoratorMetadata | Supported, used by NestJS DI | Not supported — no metadata emission |
| Method decorator signature | (target, key, descriptor) | (value, context) — context is an object |
| Class decorator return | Can return new class | Can return new class |
| Initialization order | Outer-to-inner, bottom-up | Defined precisely in spec |
| Field decorators | Limited, no initial value access | Full access to initializer |
| Metadata API | reflect-metadata (third-party) | Native Symbol.metadata (stage 3 proposal) |
The practical consequence: you cannot use NestJS with standard decorators today. NestJS’s entire DI system depends on emitDecoratorMetadata, which is incompatible with the standard system. NestJS 11 (2025) is working toward standard decorator support, but the ecosystem migration is ongoing.
// tsconfig.json for NestJS — legacy system required
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
// ...
}
}
// tsconfig.json for a project NOT using NestJS, using standard decorators
{
"compilerOptions": {
// No experimentalDecorators needed for standard TC39 decorators in TS 5.0+
// ...
}
}
When you start a new NestJS project with nest new, the CLI sets these flags automatically. When you encounter an article or library using decorators, check which system it targets — the two are not interchangeable.
Key Differences
| Concept | C# Attributes | TypeScript Decorators (Legacy) |
|---|---|---|
| Execution time | Never — metadata stored, not executed | At class definition (module load) time |
| What they can do | Store metadata only | Modify the target, store metadata, or both |
| Type safety | AttributeUsage enforced by compiler | No compile-time enforcement of where decorators can be applied |
| Composition | Apply multiple attributes, no built-in composition | Composed with applyDecorators() (NestJS) or manual stacking |
| Access to type info | Full reflection at any time | Only available via emitDecoratorMetadata + reflect-metadata |
| Order of execution | No order (reflection reads all at once) | Bottom-to-top for stacked decorators, outer factory first |
| DI integration | Framework reads constructor parameter types via reflection | design:paramtypes emitted by tsc, read by DI container at bootstrap |
| Modification of target | Cannot modify the decorated member | Can replace method implementations, wrap constructors |
| Runtime overhead | Reflection cost at read time | None at call time — modification applied at load time |
| Standardization | Language-level feature since C# 1.0 | Legacy system non-standard; standard system in TS 5.0+ |
Gotchas for .NET Engineers
Gotcha 1: Decorator Execution Order Is Bottom-to-Top, and Decorator Factories Run Top-to-Bottom
When you stack multiple decorators, C# attributes have no defined execution order — the framework reads them in whatever order GetCustomAttributes() returns. TypeScript decorators have a specific order that will surprise you.
For stacked decorators, factories (the outer function calls) execute top-to-bottom, but the actual decorator functions (the returned inner functions) execute bottom-to-top:
function First() {
console.log('First factory called'); // 1st
return function (target: object, key: string, desc: PropertyDescriptor) {
console.log('First decorator applied'); // 4th
};
}
function Second() {
console.log('Second factory called'); // 2nd
return function (target: object, key: string, desc: PropertyDescriptor) {
console.log('Second decorator applied'); // 3rd
};
}
class Example {
@First()
@Second()
method() {}
}
// Output:
// First factory called
// Second factory called
// Second decorator applied ← inner functions run bottom-to-top
// First decorator applied
This matters when decorators wrap a method. The last decorator applied (bottom) wraps the original implementation first. The first decorator (top) wraps the already-wrapped version. The outer wrapper executes first at call time. In .NET, you think about filter order — here you think about wrapping order.
Gotcha 2: emitDecoratorMetadata Erases Types You Expect to Be Available
When emitDecoratorMetadata: true is set, TypeScript emits type information for constructor parameters — but only for parameters whose types resolve to something concrete at runtime. Generic types, union types, and interface types do not survive.
// This works — UserRepository is a class, so its constructor function is emitted.
@Injectable()
class UserService {
constructor(private readonly repo: UserRepository) {}
}
// This SILENTLY FAILS — interfaces do not exist at runtime.
// The emitted paramtype will be Object, not IUserRepository.
// NestJS cannot resolve IUserRepository from the DI container.
@Injectable()
class UserService {
constructor(private readonly repo: IUserRepository) {} // ← runtime: Object
}
// Fix: use injection tokens explicitly.
import { Inject } from '@nestjs/common';
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');
@Injectable()
class UserService {
constructor(
@Inject(USER_REPOSITORY) private readonly repo: IUserRepository,
) {}
}
This trips up .NET engineers who are accustomed to programming against interfaces and having DI resolve them automatically. In NestJS, DI works against class types (whose constructor functions are real runtime values) or against explicit tokens. If you inject an interface type without @Inject(), NestJS will resolve whatever happens to be registered under the key Object — which is almost certainly wrong, and the error will not be obvious.
Gotcha 3: Decorator Side Effects Run at Import Time, Not at Request Time
In ASP.NET Core, your attribute instances are created on demand when the framework needs them — typically during startup reflection or at the point of a specific request (for some filters). In TypeScript, your decorator functions run the moment the module is imported.
This means any code inside a decorator that has side effects — database connections, HTTP calls, filesystem access — runs at module load time, before your application is “ready,” and before any dependency injection is available.
// This runs when the module is imported. Not when the class is instantiated.
// Not when the method is called. At import time.
function CacheResult(ttlSeconds: number) {
// If you try to access a DI container here, it does not exist yet.
const cache = new Map<string, unknown>(); // Fine — in-memory
// const cache = redis.connect(); // This would fail at import time
return function (target: object, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value as (...args: unknown[]) => Promise<unknown>;
descriptor.value = async function (...args: unknown[]) {
const cacheKey = JSON.stringify(args);
if (cache.has(cacheKey)) return cache.get(cacheKey);
const result = await original.apply(this, args);
cache.set(cacheKey, result);
setTimeout(() => cache.delete(cacheKey), ttlSeconds * 1000);
return result;
};
};
}
The pattern for decorators that need services (like a logger, a cache client, or a repository) is to store a reference or a token in metadata at decoration time, then resolve the dependency at call time when the DI container is available. NestJS’s @Inject() follows exactly this pattern — it stores the injection token in metadata during decoration, and the framework resolves the actual instance when constructing the controller.
Gotcha 4: Circular Imports Break Decorator Metadata Silently
This is a Node.js module system issue that manifests specifically in decorator-heavy code. When module A decorates a class using a token defined in module B, and module B imports from module A (a circular dependency), one of the imports will resolve to undefined at the point the decorator runs — because the other module has not finished loading yet.
In .NET, the assembly linker resolves all type references before any code runs. In Node.js, module loading is sequential and circular references can produce undefined at import time.
// users.module.ts — imports auth.module.ts
// auth.module.ts — imports users.module.ts
// Circular. One of them will import undefined from the other.
// Symptom in NestJS: "Nest can't resolve dependencies of UserService (?).
// Please make sure that the argument AuthService at index [0] is available."
// Fix: use forwardRef() to defer the reference resolution.
import { forwardRef } from '@nestjs/common';
@Module({
imports: [forwardRef(() => AuthModule)],
providers: [UserService],
})
export class UsersModule {}
If you see NestJS DI resolution errors that mention ? as a dependency, or errors about circular dependencies, look for circular imports in your module graph before assuming the decorator configuration is wrong.
Gotcha 5: Decorators Applied to Abstract or Base Classes Do Not Automatically Apply to Subclasses
In C#, attributes applied to a virtual method in a base class are visible when reflecting on the override in a subclass (with inherit: true in GetCustomAttributes). TypeScript decorator behavior is different — decorators are applied to the specific class they appear on. A decorator on BaseController does not automatically apply to UsersController extends BaseController.
// C# behavior you might expect:
// [Authorize] on BaseController protects all subclass routes via inheritance.
// TypeScript: @UseGuards(AuthGuard) on BaseController does NOT protect subclasses.
// You must apply it to each subclass or use a global guard.
// Wrong assumption:
class BaseController {
@UseGuards(AuthGuard) // Only protects methods directly on BaseController
protected doSomething() {}
}
class UsersController extends BaseController {
@Get()
getUsers() {} // NOT protected by AuthGuard
}
// Correct approach in NestJS: global guard or explicit guard on each controller.
app.useGlobalGuards(new AuthGuard());
Hands-On Exercise
Build a complete custom validation system using decorators and reflect-metadata that mirrors what class-validator does internally. This exercise teaches you the mechanics that underpin NestJS’s validation pipes.
Setup:
mkdir decorator-exercise && cd decorator-exercise
npm init -y
npm install reflect-metadata typescript ts-node
npx tsc --init
Update tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strict": true
}
}
Step 1 — Build the validation decorators:
Create src/validators.ts:
import 'reflect-metadata';
const VALIDATORS_KEY = 'validators';
type ValidatorFn = (value: unknown, key: string) => string | null;
function addValidator(target: object, propertyKey: string, validator: ValidatorFn) {
const existing: Map<string, ValidatorFn[]> =
Reflect.getMetadata(VALIDATORS_KEY, target) ?? new Map();
const forKey = existing.get(propertyKey) ?? [];
forKey.push(validator);
existing.set(propertyKey, forKey);
Reflect.defineMetadata(VALIDATORS_KEY, existing, target);
}
export function IsString() {
return function (target: object, propertyKey: string) {
addValidator(target, propertyKey, (value, key) =>
typeof value !== 'string' ? `${key} must be a string` : null,
);
};
}
export function MinLength(min: number) {
return function (target: object, propertyKey: string) {
addValidator(target, propertyKey, (value, key) =>
typeof value === 'string' && value.length < min
? `${key} must be at least ${min} characters`
: null,
);
};
}
export function IsEmail() {
return function (target: object, propertyKey: string) {
addValidator(target, propertyKey, (value, key) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return typeof value === 'string' && !emailRegex.test(value)
? `${key} must be a valid email address`
: null;
});
};
}
export function validate(instance: object): string[] {
const validators: Map<string, ValidatorFn[]> =
Reflect.getMetadata(VALIDATORS_KEY, instance) ?? new Map();
const errors: string[] = [];
for (const [key, fns] of validators) {
const value = (instance as Record<string, unknown>)[key];
for (const fn of fns) {
const error = fn(value, key);
if (error) errors.push(error);
}
}
return errors;
}
Step 2 — Apply the decorators to a DTO:
Create src/create-user.dto.ts:
import { IsString, MinLength, IsEmail } from './validators';
export class CreateUserDto {
@IsString()
@MinLength(2)
name: string = '';
@IsEmail()
email: string = '';
@IsString()
@MinLength(8)
password: string = '';
}
Step 3 — Validate instances:
Create src/index.ts:
import { CreateUserDto } from './create-user.dto';
import { validate } from './validators';
const validDto = new CreateUserDto();
validDto.name = 'Alice';
validDto.email = 'alice@example.com';
validDto.password = 'securepassword';
const invalidDto = new CreateUserDto();
invalidDto.name = 'A';
invalidDto.email = 'not-an-email';
invalidDto.password = 'short';
console.log('Valid DTO errors:', validate(validDto));
// []
console.log('Invalid DTO errors:', validate(invalidDto));
// [
// 'name must be at least 2 characters',
// 'email must be a valid email address',
// 'password must be at least 8 characters'
// ]
Run it:
npx ts-node src/index.ts
Extension tasks:
- Add an
@IsOptional()decorator that marks a field as skippable when undefined. Validation should not run on optional fields with undefined values. - Add an
@IsNumber()decorator. UseemitDecoratorMetadataandReflect.getMetadata('design:type', target, propertyKey)to infer the type without an explicit decorator — see if you can validate numeric fields automatically based on their TypeScript type. - Implement a
@ValidateNested()decorator that runsvalidate()recursively on nested DTO instances, building a nested error structure. Compare this to howclass-validator’s@ValidateNested()and@Type()work.
Quick Reference
| C# Concept | TypeScript / NestJS Equivalent | Key Difference |
|---|---|---|
[HttpGet("/users")] | @Get('/users') | Decorator runs at load time; attribute is lazy metadata |
[ApiController] | @Controller('users') | Same intent; TS stores metadata, ASP reads it |
[Authorize(Roles = "Admin")] | @UseGuards(AdminGuard) | TS guard is class-based; attribute is data-driven |
[FromBody] CreateUserDto dto | @Body() dto: CreateUserDto | Both bind request body; param decorator stores index |
[FromRoute] int id | @Param('id', ParseIntPipe) id: number | Pipes transform; pipe replaces model binding |
[Service] / services.AddScoped<T>() | @Injectable() + register in @Module providers | DI auto-wires via design:paramtypes metadata |
IServiceCollection.AddScoped<I, T>() | { provide: InjectionToken, useClass: T } | Interfaces not real at runtime; use tokens |
Custom Attribute subclass | Decorator factory function | TS decorator is a function; attribute is a class |
GetCustomAttribute<T>(method) | Reflect.getMetadata(key, target, method) | Both read stored metadata; TS needs explicit import |
AttributeUsage(AttributeTargets.Method) | No built-in enforcement | TypeScript does not restrict decorator targets at compile time |
emitDecoratorMetadata | "emitDecoratorMetadata": true in tsconfig | Emits design:paramtypes — required for NestJS DI |
applyDecorators(A, B) | [A, B] stacked on a method | NestJS utility to compose multiple decorators into one |
forwardRef<T>() | forwardRef(() => T) | Breaks circular module dependencies in NestJS |
experimentalDecorators flag | "experimentalDecorators": true | Legacy system; required for NestJS, TypeORM, class-validator |
| TC39 Stage 3 decorators | TS 5.0+ without the flag | Standard system; not compatible with NestJS currently |
Further Reading
- TypeScript Decorators — TypeScript Handbook — The authoritative reference for the legacy decorator system. Covers class, method, property, and parameter decorators with examples.
- NestJS Custom Decorators — Official NestJS documentation for
createParamDecorator,applyDecorators, and composing decorators in the NestJS DI context. - reflect-metadata — GitHub — The library that implements the Metadata Reflection API. The README explains the proposal and the API design. Useful background for understanding why NestJS’s DI behaves the way it does.
- TC39 Decorators Proposal — The Stage 3 proposal repository. Includes the motivation document explaining what changed from the legacy system and why.