One of my favorite features in TypeScript is the ability to use decorators. Decorators were introduced as an experimental feature and are pieces of code that we can use to modify the behavior of individual classes without having to change the internal implementation of the class. With this concept, we can adapt the behavior of an existing class without having to subclass it.
If you have come to TypeScript from a language such as Java or C#, you might notice that decorators look a lot like a technique known as AOP. What AOP techniques provide us with is the ability to extract repetitive code by cutting across a piece of code and separating this out into a different location. This means that we do not have to litter our implementations with code that will largely be boilerplate code, but which absolutely must be present in the running application.
The easiest way to explain what a decorator is to start off with an example. Suppose we have a class where only users in certain roles can access certain methods, as follows:
interface IDecoratorExample {
AnyoneCanRun(args:string) : void;
AdminOnly(args:string) : void;
}
class NoRoleCheck implements IDecoratorExample {
AnyoneCanRun(args: string): void {
console.log(args);
}
AdminOnly(args: string): void {
console.log(args);
}
}
Now, we are going to create a user who has the admin and user roles, meaning that there are no problems in calling both methods in this class:
let currentUser = {user: "peter", roles : [{role:"user"}, {role:"admin"}] };
function TestDecoratorExample(decoratorMethod : IDecoratorExample) {
console.log(`Current user ${currentUser.user}`);
decoratorMethod.AnyoneCanRun(`Running as user`);
decoratorMethod.AdminOnly(`Running as admin`);
}
TestDecoratorExample(new NoRoleCheck());
This gives us our expected output, as follows:
Current user Peter
Running as user
Running as admin
If we were to create a user who only had the user role, we would expect that they should not be able to run the admin-only code. As our code has no role checking, the AdminOnly method will be run regardless of what roles the user has assigned. One way to fix this code would be to add code to check the entitlement and then add this inside each method.
First, we are going to create a simple function to check whether or not the current user belongs to a particular role:
function IsInRole(role : string) : boolean {
return currentUser.roles.some(r => r.role === role);
}
Revisiting our existing implementation, we are going to change our functions to call this check and determine whether or not user is allowed to run that method:
AnyoneCanRun(args: string): void {
if (!IsInRole("user")) {
console.log(`${currentUser.user} is not in the user role`);
return;
};
console.log(args);
}
AdminOnly(args: string): void {
if (!IsInRole("admin")) {
console.log(`${currentUser.user} is not in the admin role`);
};
console.log(args);
}
When we look at this code, we can see that there is a lot of repeated code in here. Worse still, while we have repeated code, there is a bug in this implementation. In the AdminOnly code, there is no return statement inside the IsInRole block so the code will still run the AdminOnly code, but it will tell us that the user is not in the admin role and will then output the message regardless. This highlights one of the problems with repeated code: it's very easy to introduce subtle (or not-so-subtle) bugs without realizing it. Finally, we are violating one of the basic principles of good object-oriented (OO) development practice. Our classes and methods are doing things that they should not be doing; the code should be doing one thing and one thing only, so checking roles does not belong there. In Chapter 2, Creating a Markdown Editor with TypeScript, we will cover this in more depth when we delve deeper into the OO development mindset.
Let's see how we can use a method decorator to remove the boilerplate code and address the single responsibility issue.
Before we write our code, we need to ensure that TypeScript knows that we are going to use decorators, which are an experimental ES5 feature. We can do this by running the following command from the command line:
tsc --target ES5 --experimentalDecorators
Or, we can set this up in our tsconfig file:
"compilerOptions": {
"target": "ES5",
// other parameters….
"experimentalDecorators": true
}
With the decorator build features enabled, we can now write our first decorator to ensure that a user belongs to the admin role:
function Admin(target: any, propertyKey : string | symbol, descriptor : PropertyDescriptor) {
let originalMethod = descriptor.value;
descriptor.value = function() {
if (IsInRole(`admin`)) {
originalMethod.apply(this, arguments);
return;
}
console.log(`${currentUser.user} is not in the admin role`);
}
return descriptor;
}
Whenever we see a function definition that looks similar to this, we know that we are looking at a method decorator. TypeScript expects exactly these parameters in this order:
function …(target: any, propertyKey : string | symbol, descriptor : PropertyDescriptor)
The first parameter is used to refer to the element that we are applying it to. The second parameter is the name of the element, and the last parameter is the descriptor of the method we are applying our decorator to; this allows us to alter the behavior of the method. We must have a function with this signature to use as our decorator:
let originalMethod = descriptor.value;
descriptor.value = function() {
...
}
return descriptor;
The internals of the decorator method are not as scary as they look. What we are doing is copying the original method from the descriptor and then replacing that method with our own custom implementation. This wrapped implementation is returned and will be the code that is executed when we encounter it:
if (IsInRole(`admin`)) {
originalMethod.apply(this, arguments);
return;
}
console.log(`${currentUser.user} is not in the admin role`);
In our wrapped implementation, we are performing the same role check. If the check passes, we apply the original method. By using a technique like this, we have added something that will avoid calling our methods if it does not need to in a consistent manner.
In order to apply this, we use @ in front of our decorator factory function name just before the method in our class. When we add our decorator, we must avoid putting a semicolon between it and the method, as follows:
class DecoratedExampleMethodDecoration implements IDecoratorExample {
AnyoneCanRun(args:string) : void {
console.log(args);
}
@Admin
AdminOnly(args:string) : void {
console.log(args);
}
}
While this code works for the AdminOnly code, it is not particularly flexible. As we add more roles, we will end up having to add more and more virtually identical functions. If only we had a way to create a general-purpose function that we could use to return a decorator that would accept a parameter that sets the role we wanted to allow. Fortunately, there is a way that we can do this using something called a decorator factory.
Put simply, a TypeScript decorator factory is a function that can receive parameters and uses the parameters to return the actual decorator. It only needs a couple of minor tweaks to our code and we have a working factory where we can specify the role we want to guard:
function Role(role : string) {
return function(target: any, propertyKey : string | symbol, descriptor
: PropertyDescriptor) {
let originalMethod = descriptor.value;
descriptor.value = function() {
if (IsInRole(role)) {
originalMethod.apply(this, arguments);
return;
}
console.log(`${currentUser.user} is not in the ${role} role`);
}
return descriptor;
}
}
The only real differences here are that we have a function returning our decorator, which no longer has a name, and the factory function parameter is being used inside our decorator. We can now change our class to use this factory instead:
class DecoratedExampleMethodDecoration implements IDecoratorExample {
@Role("user") // Note, no semi-colon
AnyoneCanRun(args:string) : void {
console.log(args);
}
@Role("admin")
AdminOnly(args:string) : void {
console.log(args);
}
}
With this change, when we call our methods, only an admin will be able to access the AdminOnly method, while anyone who is a user will be able to call AnyoneCanRun. An important side note is that, our decorator only applies inside a class. We cannot use this on a standalone function.
The reason we call this technique a decorator is because it follows something called the decorator pattern. This pattern recognizes a technique that is used to add behavior to individual objects without affecting other objects from the same class and without having to create a subclass. A pattern is simply a formalized solution to problems that occur commonly in software engineering, so the names act as a useful shorthand for describing what is going on functionally. It will probably not come as much of a surprise to know that there is also a factory pattern. As we go through this book, we will encounter other examples of patterns, so we will be comfortable using them when we reach the end.
We can apply decorators to other items in a class as well. For instance, if we wanted to prevent an unauthorized user from even instantiating our class, we could define a class decorator. A class decorator is added to the class definition and expects to receive the constructor as a function. This is what our constructor decorator looks like when created from a factory:
function Role(role : string) {
return function(constructor : Function) {
if (!IsInRole (role)) {
throw new Error(`The user is not authorized to access this class`);
}
}
}
When we apply this, we follow the same format of using the @ prefix, so, when the code attempts to create a new instance of this class for a non-admin user, the application will throw an error, preventing this class from being created:
@Role ("admin")
class RestrictedClass {
constructor() {
console.log(`Inside the constructor`);
}
Validate() {
console.log(`Validating`);
}
}
We can see that we have not declared any of our decorators inside a class. We should always create them as a top-level function because their usage is not suited for decorating a class, so we will not see syntax such as @MyClass.Role("admin");.
Beyond constructor and method decorations, we can decorate properties, accessors, and more. We aren't going to go into these here, but they will be cropping up later on in this book. We will also be looking at how we can chain decorators together so we have a syntax that looks as follows:
@Role ("admin")
@Log(“Creating RestrictedClass”)
class RestrictedClass {
constructor() {
console.log(`Inside the constructor`);
}
Validate() {
console.log(`Validating`);
}
}