The power of TypeScript decorators: real cases. Decorating class methods.

Andrei Chmelev
11 min readDec 12, 2022

--

Photo by TOMMY VAN KESSEL on Unsplash

A decorator is a declarative programming tool. They help easily and elegantly add metadata to classes and class members. Based on this metadata, one can extend or modify the behavior of classes and class members without changing the code base to which the decorator is applied. The technology itself can be classified as metaprogramming or declarative programming.

This article discusses several examples from real-world projects where the use of decorators has simplified the code significantly and eliminated duplication.

What problem do decorators address?

Using decorators, we can avoid code duplication by encapsulating a cross-cutting concern in a standalone module. We can also get rid of code noise so the code writer can focus on the business logic of the application they develop.

A cross-cutting concern is a function that is spread across the code base. Usually, such concerns don’t depend on the project’s field. It includes the following:

  • Logging
  • Caching
  • Validation
  • Formatting
  • etc.

There is a paradigm that deals with cross-cutting concerns, aspect-oriented programming (AOP). You might want to read this great text to learn more about how it works in JavaScript. There are also some brilliant libraries implementing AOP in JavaScript:

If you’re interested in AOP, you may want to install these packages and experiment with their functionality.

In this article, I tried to show how one can resolve the problems described above using TypeScript’s built-in functionality, decorators.

I assume that you are experienced in react, mobx, and typescript, so I won’t delve into specifics of these technologies.

Decorators in general

In TypeScript, a decorator is a function.

It looks like this: @funcName. Here, funcName is the name of the function that describes the decorator. Once a decorator is assigned to a class member and the latter is called, decorators will be executed first, and then the class code. However, a decorator can interrupt the code execution on its level so that the main class code won’t be executed. If a class member has several decorators, they are executed from top to bottom, one by one.

Decorators are still an experimental TypeScript function. To use them, you need to add the following setting to your tsconfig.json:

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

A decorator is called by the compiler, and the latter adds the arguments to the decorator function automatically.

The signature of this function for class methods is as follows:

funcName<TCls, TMethod>(target: TCls, key: string, descriptor: TypedPropertyDescriptor<TMethod>): TypedPropertyDescriptor<TMethod> | void

Where:

  • target is the object for which the decorator will be used
  • key is the class method that is decorated
  • descriptor is the class method descriptor

Using a descriptor, we can access the original method of the object.

Using template typing, you can prevent the incorrect use of a decorator. For example, you can limit the use of a decorator to the class methods whose first argument is always a string. To do that, define the descriptor type the following way:

type TestDescriptor = TypedPropertyDescriptor<(id: string, ...args: any[]) => any>;

In our examples, we will use decorator factories. A decorator factory is a function that returns the function called by the decorator during execution.

function format(pattern: string) {
// this is a decorator factory and it returns a decorator function
return function (target) {
// it's a decorator. Here will be the code
// that does something with target and pattern
};
}

Preps

In the two following examples, we will use 2 data models:

export type Product = {
id: number;
title: string;
};

export type User = {
id: number;
firstName: string;
lastName: string;
maidenName: string;
}

In all decorator functions, we will use the PropertyDescriptor descriptor type, which is the equivalent of TypedPropertyDescriptor<any>.

Let’s add the createDecorator helper function that will help us reduce syntactic sugar of decorator creation:

export type CreateDecoratorAction<T> = (self: T, originalMethod: Function, ...args: any[]) => Promise<void> | void;

export function createDecorator<T = any>(action: CreateDecoratorAction<T>) {
return (target: T, key: string, descriptor: PropertyDescriptor) => {
const originalMethod = descriptor.value; // reference to the original class method
// override class method
descriptor.value = async function (...args: any[]) {
const _this = this as T;
await action(_this, originalMethod, ...args);
};
};
}

The project is built on React + TypeScript. To display the application state on the screen, we use this wonderful library, Mobx. In the examples below, I’ll skip Mobx-related code to direct your attention to the problem and its solution.

You can find the full code in this repository.

Displaying the data loading indicator

First, let’s create the AppStore class that will contain the entire state of our small application. The application will comprise two lists: users and products. We’ll take this data from dummyjson.

When the page is rendered, two server requests are sent to load the lists. This is what AppStore looks like:

class AppStore {
users: User[] = [];
products: Product[] = [];
usersLoading = false;
productsLoading = false;

async loadUsers() {
if (this.usersLoading) {
return;
}
try {
this.setUsersLoading(true);
const resp = await fetch("https://dummyjson.com/users");
const data = await resp.json();
const users = data.users as User[];
this.users = users;
} finally {
this.setUsersLoading(false);
}
}
async loadProducts() {
if (this.productsLoading) {
return;
}
try {
this.setProductsLoading(true);
const resp = await fetch("https://dummyjson.com/products");
const data = await resp.json();
const products = data.users as Product[];
this.products = products;
} finally {
this.setProductsLoading(false);
}
}

private setUsersLoading(value: boolean) {
this.usersLoading = value;
}

private setProductsLoading(value: boolean) {
this.usersLoading = value;
}
}

By changing the value of usersLoading and productsLoading flags, we can control whether list loading indicators are shown. As you can see in the above code, this functionality is repeated in the methods. Now we’ll try to use the decorators to remove this duplication. We encapsulate all the loading flags in one object, which will be stored in the loading property of our state store. For that, let’s define the interface and base class (to reuse the code to control flag loading state):

type KeyBooleanValue = {
[key: string]: boolean;
};

export interface ILoadable<T> {
loading: T;
setLoading(key: keyof T, value: boolean): void;
}

export abstract class Loadable<T> implements ILoadable<T> {
loading: T;
constructor() {
this.loading = {} as T;
}

setLoading(key: keyof T, value: boolean) {
(this.loading as KeyBooleanValue)[key as string] = value;
}
}

If you can’t use inheritance, you can use ILoadable and implement the setLoading method.

Now let’s isolate the general flag state control functionality in the decorator. For that, we’ll create a generalized decorator factory loadable by using the createDecorator helper function:

export const loadable = <T>(keyLoading: keyof T) =>
createDecorator<ILoadable<T>>(async (self, method, ...args) => {
try {
if (self.loading[keyLoading]) return;
self.setLoading(keyLoading, true);
return await method.call(self, ...args);
} finally {
self.setLoading(keyLoading, false);
}
});

The factory function is generalized and takes as input the property keys of the object that will be stored in the loading property of the ILoadable interface. To make sure this decorator is used correctly, our class needs to implement the ILoadable interface. We’ll use inheritance from the Loadable class that already implements this interface, and rewrite our code the following way:

const defaultLoading = {
users: false,
products: false,
};

class AppStore extends Loadable<typeof defaultLoading> {
users: User[] = [];
products: Product[] = [];

constructor() {
super();
this.loading = defaultLoading;
}

@loadable("users")
async loadUsers() {
const resp = await fetch("https://dummyjson.com/users");
const data = await resp.json();
const users = data.users as User[];
this.users = users;
}
@loadable("products")
async loadProducts() {
const resp = await fetch("https://dummyjson.com/products");
const data = await resp.json();
const products = data.users as Product[];
this.products = products;
}
}

As an object type in the loading property, we pass the dynamically calculated type typeof defaultLoading from the object’s default state defaultLoading. We also assign this state to the loading property. This way, the string keys that we pass to the loadable decorator are controlled by typescript typing. As you can see, loadUsers and loadProducts methods read better, and spinner display functionality is encapsulated in a standalone module. The loadable decorator factory and ILoadable interface are abstracted from a specific store implementation and can be used in an unlimited number of stores in an application.

Handling errors in the method

If dummyjson turns unavailable for any reason, our application with crash with an error, without the user knowing. Let’s fix that.

class AppStore extends Loadable<typeof defaultLoading> {
users: User[] = [];
products: Product[] = [];

constructor() {
super();
this.loading = defaultLoading;
}

@loadable("users")
async loadUsers() {
try {
const resp = await fetch("https://dummyjson.com/users");
const data = await resp.json();
const users = data.users as User[];
this.users = users;
} catch (error) {
notification.error({
message: "Error",
description: (error as Error).message,
placement: "bottomRight",
});
}
}
@loadable("products")
async loadProducts() {
try {
const resp = await fetch("https://dummyjson.com/products");
const data = await resp.json();
const products = data.users as Product[];
this.products = products;
} catch (error) {
notification.error({
message: "Error",
description: (error as Error).message,
placement: "bottomRight",
});
}
}
}

In every method, here appears the try … catch … block. Errors are handled in catch. An error notification pops up in the bottom right-hand corner. We’ll utilize the power of decorators and encapsulate this handling in a separate module, making it abstract:

export const errorHandle = (title?: string, desc?: string) =>
createDecorator(async (self, method, ...args) => {
try {
return await method.call(self, ...args);
} catch (error) {
notification.error({
message: title || "Error",
description: desc || (error as Error).message,
placement: "bottomRight",
});
}
});

The factory function receives optional parameters as input, including a custom header and error description, which will be shown in the notification. If these parameters are not specified, the default header and the copy from the error’s message field will be used. We’ll use the errorHandle function in our code:

class AppStore extends Loadable<typeof defaultLoading> {
users: User[] = [];
products: Product[] = [];

constructor() {
super();
this.loading = defaultLoading;
}

@loadable("users")
@errorHandle()
async loadUsers() {
const resp = await fetch("https://dummyjson.com/users");
const data = await resp.json();
const users = data.users as User[];
this.users = users;
}
@loadable("products")
@errorHandle()
async loadProducts() {
const resp = await fetch("https://dummyjson.com/products");
const data = await resp.json();
const products = data.users as Product[];
this.products = products;
}
}

This is how we easily added error handling functionality, removed code duplication, keeping the method code readable and simple.

Method success notifications

Suppose that we need to report a successful load of the user and product lists. If it weren’t for the decorators, the code would look like this:

class AppStore extends Loadable<typeof defaultLoading> {
users: User[] = [];
products: Product[] = [];

constructor() {
super();
this.loading = defaultLoading;
}

@loadable("users")
@errorHandle()
async loadUsers() {
const resp = await fetch("https://dummyjson.com/users");
const data = await resp.json();
const users = data.users as User[];
this.users = users;
notification.success({
message: "Users uploaded successfully",
placement: "bottomRight",
});
}
@loadable("products")
@errorHandle()
async loadProducts() {
const resp = await fetch("https://dummyjson.com/products");
const data = await resp.json();
const products = data.users as Product[];
this.products = products;
notification.success({
message: "Products uploaded successfully",
placement: "bottomRight",
});
}
}

Let’s encapsulate this functionality in a separate module and make it abstract:

export const successfullyNotify = (message: string, description?: string) =>
createDecorator(async (self, method, ...args) => {
const result = await method.call(self, ...args);
notification.success({
message,
description,
placement: "bottomRight",
});
return result;
});

The factory function takes as input a required parameter (notification message) and an optional parameter (message description). Let’s rewrite the code using this function:

class AppStore extends Loadable<typeof defaultLoading> {
users: User[] = [];
products: Product[] = [];

constructor() {
super();
this.loading = defaultLoading;
}

@loadable("users")
@errorHandle()
@successfullyNotify("Users uploaded successfully")
async loadUsers() {
const resp = await fetch("https://dummyjson.com/users");
const data = await resp.json();
const users = data.users as User[];
this.users = users;
}
@loadable("products")
@errorHandle()
@successfullyNotify("Products uploaded successfully")
async loadProducts() {
const resp = await fetch("https://dummyjson.com/products");
const data = await resp.json();
const products = data.users as Product[];
this.products = products;
}
}

Method logging

If you collect application data and then carry out analysis to optimize the app and record the errors, you need to add logs to the code. Let’s look at a logging case of output to the console. Here is what happens when we add logs to our service without using a decorator:

class AppStore extends Loadable<typeof defaultLoading> {
users: User[] = [];
products: Product[] = [];

constructor() {
super();
this.loading = defaultLoading;
}

@loadable("users")
@errorHandle()
@successfullyNotify("Users uploaded successfully")
async loadUsers() {
try {
console.log(`Before calling the method loadUsers`);
const resp = await fetch("https://dummyjson.com/users");
const data = await resp.json();
const users = data.users as User[];
this.users = users;
console.log(`The method loadUsers worked successfully.`);
} catch (error) {
console.log(`An exception occurred in the method loadUsers. Exception message: `, (error as Error).message);
throw error;
} finally {
console.log(`The method loadUsers completed`);
}
}
@loadable("products")
@errorHandle()
@successfullyNotify("Products uploaded successfully")
async loadProducts() {
try {
console.log(`Before calling the method loadProducts`);
const resp = await fetch("https://dummyjson.com/products");
const data = await resp.json();
const products = data.users as Product[];
this.products = products;
console.log(`The method loadProducts worked successfully.`);
} catch (error) {
console.log(`An exception occurred in the method loadProducts. Exception message: `, (error as Error).message);
throw error;
} finally {
console.log(`The method loadProducts completed`);
}
}
}

As you can see, the method code turned more complicated and less comprehensive. This code lacks a part that enables or disables logs depending on the build stage (or based on any other criteria). All this makes the code even clumsier. Let’s create a versatile logging decorator.

export type LogPoint = "before" | "after" | "error" | "success";

let defaultLogPoint: LogPoint[] = ["before", "after", "error", "success"];
export function setDefaultLogPoint(logPoints: LogPoint[]) {
defaultLogPoint = logPoints;
}

export const log = (points = defaultLogPoint) =>
createDecorator(async (self, method, ...args) => {
try {
if (points.includes("before")) {
console.log(`Before calling the method ${method.name} with args: `, args);
}
const result = await method.call(self, ...args);
if (points.includes("success")) {
console.log(`The method ${method.name} worked successfully. Return value: ${result}`);
}
return result;
} catch (error) {
if (points.includes("error")) {
console.log(
`An exception occurred in the method ${method.name}. Exception message: `,
(error as Error).message
);
}
throw error;
} finally {
if (points.includes("after")) {
console.log(`The method ${method.name} completed`);
}
}
});

In this decorator, we have defined logging points that you can customize by passing their typed array to the first parameter of the decorator factory. By default, everything is logged. Using the setDefaultLogPoint function, you can redefine the default logging points. Let’s implement this factory in our code:

class AppStore extends Loadable<typeof defaultLoading> {
users: User[] = [];
products: Product[] = [];

constructor() {
super();
this.loading = defaultLoading;
}

@loadable("users")
@errorHandle()
@successfullyNotify("Users uploaded successfully")
@log()
async loadUsers() {
const resp = await fetch("https://dummyjson.com/users");
const data = await resp.json();
const users = data.users as User[];
this.users = users;
}
@loadable("products")
@errorHandle()
@successfullyNotify("Products uploaded successfully")
@log()
async loadProducts() {
const resp = await fetch("https://dummyjson.com/products");
const data = await resp.json();
const products = data.users as Product[];
this.products = products;
}
}

Such encapsulation helps us control logging activation in the application. To sum up, we added a ton of new functionality without affecting the code or the application’s business logic.

Takeaways

Decorators boast great potential. They help address a wide range of tasks and make the code easy to read. Using them, we can easily isolate a recurring cross-cutting concern in modules and apply them in other parts of the project or in other projects. However, such a powerful tool may cause harm when used improperly. For example, it can affect the designated purpose of a method. But this is not the reason for you not to utilize this fabulous technology in your projects. Decorators will complement your projects, both in object-oriented programming and in the functional paradigm.

You can find the source in this repository

--

--