Build a Logger and Validator with TypeScript Decorators (Like NestJS)

Sascha

Команда форума
Администратор
Ofline
https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frwbxv43wyx7aaqh9immn.png


Note: This post is a translated version of an article originally published on my personal blog. You can read the original Korean post here.


What Are Decorators?​


Decorators are a metaprogramming syntax that lets you attach metadata — or extend behavior — on classes, methods, properties, and parameters without modifying the original code.

They're commonly used for:

  • Logging — track when methods are called
  • Validation — enforce constraints on class properties
  • Dependency Injection — wire up services automatically
  • Authorization — guard methods behind permission checks

Frameworks like Angular and NestJS lean heavily on decorators for all of the above.


Setting Up​


First, enable decorators in your tsconfig.json:


Код:
{
  "compilerOptions": {
    "experimentalDecorators": true
  }
}



How Decorators Work​


A decorator is just a function. When you apply it to a class or method, TypeScript calls that function at definition time with some useful arguments.


Код:
// simple-decorator.ts

export function SimpleDecorator(
  target: any,                              // class prototype or constructor
  propertyKey?: string,                     // method or property name
  descriptor?: PropertyDescriptor | number, // property descriptor or param index
) {
  console.log('====================================================');
  console.log('Target', target);
  console.log('Property Key', propertyKey);
  console.log('Descriptor', descriptor);
}



Let's apply it everywhere to see what gets passed in:


Код:
// example.ts

import { SimpleDecorator } from './simple-decorator';

@SimpleDecorator
class Example {
  @SimpleDecorator
  private _myProperty01: string = '';

  @SimpleDecorator
  get property() {
    return this._myProperty01;
  }

  @SimpleDecorator
  foo(
    @SimpleDecorator
    name: string,
    @SimpleDecorator
    age: number,
  ) {
    return `Name: ${name}, Age: ${age}`;
  }
}



For a method decorator, the output looks like:


Код:
// Target {}              ← Example.prototype
// Property Key foo       ← method name
// Descriptor {
//   value: [Function: foo],
//   writable: true,
//   enumerable: false,
//   configurable: true
// }



Key takeaway: descriptor.value is the actual method function. Swap it out inside the decorator, and you've changed how the method behaves — without touching the original class.


Quick Summary​

ArgumentWhat it is
targetClass prototype (for methods/properties) or constructor (for class decorators)
propertyKeyThe name of the decorated method or property
descriptor PropertyDescriptor for methods, parameter index for parameter decorators

Building a Logger Decorator​


Now let's build something real. This Logger decorator wraps a method and logs before/after each call.


Код:
export interface LoggerOptions {
  mode: 'simple' | 'detailed';
}

export function Logger({ mode }: LoggerOptions = { mode: 'simple' }) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value; // save original method

    // replace with a wrapper function
    descriptor.value = function (...args: any[]) {
      console.log(`Calling ${propertyKey}`);
      if (mode === 'detailed') {
        console.log('Arguments:', args);
      }

      const result = originalMethod.apply(this, args); // call original

      console.log(`${propertyKey} finished`);
      if (mode === 'detailed') {
        console.log('Return value:', result);
      }

      return result;
    };

    return descriptor;
  };
}



A few things happening here:

  1. Logger is a decorator factory — it accepts options and returns the actual decorator function. This pattern lets you parameterize your decorators.
  2. We stash descriptor.value (the original method) before replacing it.
  3. The new function logs, calls the original, logs again, and returns the result.

In action:


Код:
import { Logger } from './logger';

class Example2 {
  @Logger({ mode: 'detailed' })
  foo(name: string, age: number) {
    return `Name: ${name}, Age: ${age}`;
  }
}

const example2 = new Example2();
const returnValue = example2.foo('Ina', 20);
// Calling foo
// Arguments: [ 'Ina', 20 ]
// foo finished
// Return value: Name: Ina, Age: 20



The method works exactly as before — we just wrapped it.


Building a Validator Decorator​


Now for something a bit more complex. We'll use reflect-metadata to store validation rules on properties, then run them with a validate() function.

Install the package first:


Код:
npm install reflect-metadata


Step 1 — Define the MinLength property decorator​


Код:
import 'reflect-metadata';

export interface MinLength {
  length: number;
  message?: string;
}

export function MinLength({ length, message = 'Minimum length is ${length}.' }: MinLength) {
  return function (target: any, propertyKey: string) {
    // store validation metadata on the class prototype
    const constraint = { value: length, message };
    Reflect.defineMetadata('minLength', constraint, target, propertyKey);
  };
}



Reflect.defineMetadata attaches arbitrary metadata to a class property. We key it with 'minLength' so we can look it up later.

Step 2 — Write the validate function​


Код:
import 'reflect-metadata';

export function validate(target: any) {
  for (const propertyKey of Object.keys(target)) {
    // retrieve stored metadata for this property
    const constraint = Reflect.getMetadata('minLength', target, propertyKey);

    if (constraint) {
      const value = target[propertyKey];

      // run the actual check
      if (typeof value === 'string' && value.length < constraint.value) {
        const errorMessage = constraint.message.replace('${length}', constraint.value.toString());
        console.log(errorMessage);
        throw new Error(errorMessage);
      }
    }
  }

  console.log('Validation passed.');
}


Step 3 — Apply and test​


Код:
import { MinLength, validate } from './validator';

class Example {
  @MinLength({ length: 3, message: 'Name must be at least ${length} characters.' })
  value: string;

  constructor(value: string) {
    this.value = value;
  }
}

const example1 = new Example('Ina');
validate(example1); // ✅ Validation passed.

const example2 = new Example('MO');
validate(example2); // ❌ Name must be at least 3 characters.



No need to write validation logic inside the class itself — the decorator handles it declaratively.


Wrapping Up​


We covered:

  • What decorators are and the arguments they receive
  • The decorator factory pattern for passing options
  • Wrapping methods with a Logger to add pre/post logging
  • Storing validation metadata with reflect-metadata and running it with validate()

Once you understand this pattern, frameworks like NestJS become a lot less magical. Their @Injectable(), @Body(), @IsString() and friends are all doing the same thing under the hood.

You can find all the code from this post in the decorator playground repo. Clone it, run pnpm install, then pnpm test to see everything in action.

👉 Read the full post on my blog

 
Назад
Сверху Снизу