DEV Community

Cover image for How to wield the power of decorators
Valeria
Valeria

Posted on • Updated on

How to wield the power of decorators

Decorators are... No, this won't do. You've probably read the definition already and it didn't help much. Let me show you instead.

We'll need node and TypeScript for this trick. So let's set things up quickly:

mkdir decorators-example
cd decorators-example
npm init -y
npm install ts-node typescript @types/node
Enter fullscreen mode Exit fullscreen mode

Now, we need to tell TypeScript that we want it to do some magic with decorators in a folder called src.

Create file tsconfig.json with:

{
  "compilerOptions": {
    "experimentalDecorators": true
  },
  "include": ["src"]
}
Enter fullscreen mode Exit fullscreen mode

And now, let's create something to decorate. For example, we could be creating a personal blog to share our wisdom and document our path to become a JavaScript magician.

We can start with a blog post definition in src/index.ts:

class BlogPost {
  title: string;
}
const post = new BlogPost();
console.log(post.title); // undefined
Enter fullscreen mode Exit fullscreen mode

Hint: you can run this script with yarn ts-node src/index (if you have yarn) or add a script to package.json and run with npm run start afterward:

{
//...
  "scripts": {
    "start": "ts-node src/index"
  }
//...
}
Enter fullscreen mode Exit fullscreen mode

Oh, no! Our title is undefined! We don't want that, we want it to always have a default value, let's add a comment about it:

class BlogPost {
  // @defaultValue("New Post")
  title: string;
}
Enter fullscreen mode Exit fullscreen mode

Wouldn't it be nice to make this comment actually assign a default value to the post title? Well, that's precisely how a decorator looks like:


class BlogPost {
  @defaultValue("New Post")
  title: string;
}

// this is just a function
function defaultValue<T>(defaultValue: T): any {
  // that returns a (decorator) function
  return function (target: any, propertyKey: string) {
    // post is the target
    let value = target[propertyKey];
    // propertyKey is 'title' 
    // and we are re-defining this property on the post
    Object.defineProperty(target, propertyKey, {
      // if value is empty, let it return a default value
      get: () => {
        return value || defaultValue;
      },
      // but keep whatever value was set untouched
      set: (newValue: T) => {
        value = newValue;
      },
    });
  };
}

const post = new BlogPost();
console.log(post.title); // New Post
post.title = "Post";
console.log(post.title); // Post
post.title = undefined;
console.log(post.title); // New Post
Enter fullscreen mode Exit fullscreen mode

We wrote a function, that patched an object, called this function in a weird way, and let TypeScript convert it to something NodeJS can stomach.

Here's what actually got executed in NodeJS:

// TypeScript compiled to ESNext for simplicity
// Helper function
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
    var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
    else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
};

// Our function
function defaultValue(defaultValue) {
    return function (target, propertyKey) {
        let value = target[propertyKey];
        Object.defineProperty(target, propertyKey, {
            get: () => {
                return value ?? defaultValue;
            },
            set: (newValue) => {
                value = newValue;
            },
        });
    };
}

// Empty class
class BlogPost {
}

// Patching class properties
__decorate([
    defaultValue("New Post")
], BlogPost.prototype, "title", void 0);

const post = new BlogPost();
console.log(post.title); // New Post
post.title = "Post";
console.log(post.title); // Post
post.title = undefined;
console.log(post.title); // New Post
Enter fullscreen mode Exit fullscreen mode

As you can see, decorators are not magic, just a crafty illusion, but powerful nonetheless. You're already familiar with the property decorators, but there's more to it.

You can decorate methods:

function defaultValue<T>(defaultValue: T): any {
  return function (target: any, propertyKey: string) {
    // This time we add raw value to object to have access later on
    target[`_${propertyKey}`] = target[propertyKey];
    Object.defineProperty(target, propertyKey, {
      get: () => {
        return target[`_${propertyKey}`] ?? defaultValue;
      },
      set: (newValue: T) => {
        target[`_${propertyKey}`] = newValue;
      },
    });
  };
}

// Method decorator
function ifNotEmpty(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  // The method we're patching
  const fn = descriptor.value; // undefined for non-function properties
  descriptor.value = function () {
    // We call the actual method only if post has both title and content
    if (this._title && this._content) return fn.apply(this, arguments);
  };
}

class BlogPost {
  @defaultValue("New Post")
  title: string;

  @defaultValue("Post content")
  content: string;

  @ifNotEmpty
  publish() {
    return { published: true };
  }
}

const post = new BlogPost();
console.log(post.title); // New Post
console.log(post.content); // Post content
console.log(post.publish()); // undefined

post.title = "Hello, World!";
post.content = "I use decorators";

console.log(post.publish()); // { "published": true }

Enter fullscreen mode Exit fullscreen mode

And even whole classes:

function withDefaults<T extends { new (...args: any[]): {} }>(constructor: T) {
  return class extends constructor {
    title: string = `New ${constructor.name}`;
  };
}

@withDefaults
class BlogPost {
  title: string;
}

const post = new BlogPost();
console.log(post.title); // New BlogPost
Enter fullscreen mode Exit fullscreen mode

Last, but not least, you can decorate method parameters:

function required(
  target: Object,
  propertyKey: string | symbol,
  parameterIndex: number
) {
  // We can only peek on the index of the parameter
  target.constructor["_required"] = { [propertyKey]: parameterIndex };
}

function validate(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
): any {
  const fn = descriptor.value;
  descriptor.value = function () {
    const required = target.constructor["_required"][propertyKey];
    const param = arguments[required];
    if (!param) throw new Error(`missing required parameter`);
    return fn.apply(this, arguments);
  };
}

class Post {
  @validate
  static create(@required title: string) {
    return { title };
  }
}

console.log(Post.create("Hello")); // { title: 'Hello' }
console.log(Post.create("")); // Error: missing required parameter
Enter fullscreen mode Exit fullscreen mode

There's not much to add about decorators, except for a word of caution.

Decorators are meant to be used when you need to reuse functionality, that otherwise is not shareable easily. It's not a magic bullet and, since it's based on changing properties in runtime, can lead to unexpected and hard to trace bugs.

But, when used properly, decorators are expressive and can significantly speed up the development process.

For instance, I've loved the idea behind TypeORM and going to implement a lighter and testable version of it for my beloved amp-cms

What about you? Have any use for decorators yet?


Photo by Aron Visuals on Unsplash

Discussion (0)