DEV Community

Cover image for Build a Logger and Validator with TypeScript Decorators (Like NestJS)
Malloc72P
Malloc72P

Posted on • Originally published at blog.malloc72p.com

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

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

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
// }
Enter fullscreen mode Exit fullscreen mode

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

Argument What it is
target Class prototype (for methods/properties) or constructor (for class decorators)
propertyKey The 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;
  };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
  };
}
Enter fullscreen mode Exit fullscreen mode

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.');
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)