Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Decorators & Metadata: Attributes vs. Decorators

For .NET engineers who know: C# attributes, System.Reflection, custom Attribute subclasses, 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 the reflect-metadata library 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-safeAttributeUsage restricts 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:

AspectLegacy (experimentalDecorators)Standard (TC39 Stage 3)
tsconfig.json flag"experimentalDecorators": trueNo flag needed (TS 5.0+)
emitDecoratorMetadataSupported, used by NestJS DINot supported — no metadata emission
Method decorator signature(target, key, descriptor)(value, context) — context is an object
Class decorator returnCan return new classCan return new class
Initialization orderOuter-to-inner, bottom-upDefined precisely in spec
Field decoratorsLimited, no initial value accessFull access to initializer
Metadata APIreflect-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

ConceptC# AttributesTypeScript Decorators (Legacy)
Execution timeNever — metadata stored, not executedAt class definition (module load) time
What they can doStore metadata onlyModify the target, store metadata, or both
Type safetyAttributeUsage enforced by compilerNo compile-time enforcement of where decorators can be applied
CompositionApply multiple attributes, no built-in compositionComposed with applyDecorators() (NestJS) or manual stacking
Access to type infoFull reflection at any timeOnly available via emitDecoratorMetadata + reflect-metadata
Order of executionNo order (reflection reads all at once)Bottom-to-top for stacked decorators, outer factory first
DI integrationFramework reads constructor parameter types via reflectiondesign:paramtypes emitted by tsc, read by DI container at bootstrap
Modification of targetCannot modify the decorated memberCan replace method implementations, wrap constructors
Runtime overheadReflection cost at read timeNone at call time — modification applied at load time
StandardizationLanguage-level feature since C# 1.0Legacy 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:

  1. Add an @IsOptional() decorator that marks a field as skippable when undefined. Validation should not run on optional fields with undefined values.
  2. Add an @IsNumber() decorator. Use emitDecoratorMetadata and Reflect.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.
  3. Implement a @ValidateNested() decorator that runs validate() recursively on nested DTO instances, building a nested error structure. Compare this to how class-validator’s @ValidateNested() and @Type() work.

Quick Reference

C# ConceptTypeScript / NestJS EquivalentKey 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: CreateUserDtoBoth bind request body; param decorator stores index
[FromRoute] int id@Param('id', ParseIntPipe) id: numberPipes transform; pipe replaces model binding
[Service] / services.AddScoped<T>()@Injectable() + register in @Module providersDI auto-wires via design:paramtypes metadata
IServiceCollection.AddScoped<I, T>(){ provide: InjectionToken, useClass: T }Interfaces not real at runtime; use tokens
Custom Attribute subclassDecorator factory functionTS 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 enforcementTypeScript does not restrict decorator targets at compile time
emitDecoratorMetadata"emitDecoratorMetadata": true in tsconfigEmits design:paramtypes — required for NestJS DI
applyDecorators(A, B)[A, B] stacked on a methodNestJS utility to compose multiple decorators into one
forwardRef<T>()forwardRef(() => T)Breaks circular module dependencies in NestJS
experimentalDecorators flag"experimentalDecorators": trueLegacy system; required for NestJS, TypeORM, class-validator
TC39 Stage 3 decoratorsTS 5.0+ without the flagStandard 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.