A Complete Guide To Decorators In Typescript

Javascript is great but typescript, is (controversially) even better. It adds type safety to a dynamically typed language and provides some cool features like decorators.

What Are Decorators?

Decorators vary for different programming challenges but the basic point is to wrap something in it and change it's behaviour.

Although this feature is currently at stage 2 in javascript, the tc39 committee have been working on it since quite some time and they probably won't drop it any time soon.

It’s not yet available in browsers or Node.js, but you can test it out by using compilers like Babel. Having said that, it’s not exactly a brand new thing; several programming languages, such as Python, Java, and C#, adopted this pattern before JavaScript.

Even though JavaScript already has this feature proposed, Typescript’s decorators are different in a few significant ways. Since TypeScript is strongly typed, you can access some additional information associated with your data types to do some cool stuff, such as runtime type-assertion and dependency injection.

Getting Started

Start by creating a new nodejs and typescript project

mkdir ts-decorators
cd ts-decorators
npm init -y

Next, let's setup our typescript compiler

npm i -D typescript @types/node

@types/node has the type definitions for the node.js standard libraries like path, fs etc.

Add an npm script:

{
  // ...
  "scripts": {
    "build": "tsc"
  }
}

Decorators in typescript are experimental but are stable enough to be used in production. In fact, the open source community has been using it for quite some time.

To activate the feature, we'll need to make some adjustments to your tsconfig.json file.

{
  "compilerOptions": {
    "target": "ES5",
    "experimentalDecorators": true
  }
}

We'll also install a development server to compile and restart our server automatically.

npm i ts-node -D

Next, add a dev script to the package.json file:

{
  "scripts": {
    "build": "tsc",
    "start": "ts-node ./src/index.ts"
  }
}

Types of Decorators

There are various types of decorators which can be attached to classes, methods or instance fields. Let's look at them one by one and learn their use.

1. Class Decorator

When you attach a function to a class as a decorator, we’ll receive the class constructor as the first parameter.

const classDecorator = (target: Function) => {
  // do something with your class
}

@classDecorator
class Man {}

If we want to override properties/methods within the class, we can return a new class by extending the constructor and set the new properties.

const addAgeToMan = (target: Function) => {
  // "target" is the constructor of the previous class
  return class extends target {
    age = 24
  }
}

@addAgeToMan
class Man {}

Now, our class Man has an age property of 24:

const man = new Man();
console.log(man.age); // 24
2. Method Decorator

We can also attach decorators to a class method. Our decorator receives 3 arguments i.e. target, propertyKey and descriptor

const myDecorator = (target: Object, propertyKey: string, descriptor: PropertyDescriptor) =>  {
  // Do something
}
class Man {
  walk() {
    console.log('Walking in 3... 2... 1')
  }

}

The first parameter contains the class which contains the method(Man). The second(propertyKey) param contains the name of the method in string format. The last parameter is the property descriptor, a set of information that defines a property behavior. This can be used to observe, modify, or replace a method definition. We'll circle back to this later.

3.Property Decorators

Just like the method decorator, you'll receive the target and propertyKey parameter. The only difference is that you don’t get the property descriptor.

Use Cases

1. Code Execution Time

Let’s say you want to estimate how long it takes to run a function. You can create a decorator to calculate the execution time of a method and print it on the console.

class Man {
  @measure
  walk() {
    console.log('Walking in 3... 2... 1')
  }
}

The Man class has a walk() method, to measure it's execution time, we can use the @measure decorator.

import { performance } from "perf_hooks";

const measure = (
  target: Object,
  propertyKey: string,
  descriptor: PropertyDescriptor
) => {
  const originalMethod = descriptor.value;

  descriptor.value = function (...args) {
    const start = performance.now();
    const result = originalMethod.apply(this, args);
    const finish = performance.now();
    console.log(`Execution time: ${finish - start} milliseconds`);
    return result;
  };

  return descriptor;
};

As you can see, the measure decorator replaces the original method with a new one that enables it to calculate the execution time of the original method and log it to the console.

To calculate the execution time, we’ll use the Performance Hooks API from the Node.js standard library.

The result will look like this:

Launching in 3... 2... 1... 🚀
Execution time: 1.2983244 milliseconds
2. Decorator Factory

Often times we need to use the same decorators, so, we can use a concept called decorator factories

Decorators factories work using closures. They are functions that return decorators based on params we pass into them.

const changeValue = (value) => (target: Object, propertyKey: string) => {
  Object.defineProperty(target, propertyKey, { value });
};

The changeValue function returns a decorator that change the value of the property based on the value passed from your factory.

class Man {
 @changeValue(100)
 age = 24;
}

const man = new Man();
console.log(man.age); // 100

Now, if you bind your decorator factory to the age property, the value will be 100.

3. Error Handling

Let's implement a method called drink which requires the age to be at least 21.

class Man {
 drink() {
   console.log('Drinking!')
 }
}

Next, we'll create a decorator to test if the age is at least 21.

const minimumAge = (age: number) => (
target: Object,
propertyKey: string,
descriptor: PropertyDescriptor
) => {
const originalMethod = descriptor.value;

descriptor.value = function (...args) {
if (this.age > age) {
originalMethod.apply(this, args);
} else {
console.log("Not enough age!");
}
};

return descriptor;
};

The minimumAge is a factory decorator. It takes the age parameter, which indicates how much age is needed to drink.

Now, we can plug the two together and set the minimum age level.

class Man {
  age = 10;

  @minimumAge(21)
   drink() {
     console.log('Drinking!')
   }
}

If we use this, we'll get something like this:

const man = new Man()
man.drink()

// Console shows:
Not enough age!

Conclusion

Decorators can be hard to comprehend and I struggled with it too. The best way is to experiment by logging the params of various types of decorators, trying out the sample code in this post etc. That's it for now, Don't forget to like this post and follow me if you learned something new. Bye 🤟

34