TypeScript class decorators incl. Dependency Injection example

TypeScript class decorators incl. Dependency Injection example

TypeScript class decorators are heavily used in some frameworks for Dependency Injection. Learn how it works.

Β·

7 min read

By using class decorators, we have access to the constructor and also its prototype (for explanation about constructors and prototype see this MDN explanation of inheritance). Therefore, we can modify the whole class. We could add methods using its prototype, set defaults for parameters passed to the constructor, add attributes and also remove or wrap these.

Class decorator with generic constraint

In the TypeScript decorators basic post I already described the signature of the different types of decorators including the class decorator. We can use TypeScripts extends keyword to ensure the target is a constructor. That enables us to treat target as a constructor (that is why I renamed it to constructor in the following example) and use features like extending constructor.

type Constructor = {
  new (...args: any[]): {}
}
function classDecorator <T extends Constructor>(constructor: T): T | void {
  console.log(constructor)
  return class extends constructor {} // exentds works
}

// original signature as in typescript/lib/lib.es5.d.ts
// not only restricted to target being a constructor, therefore extending target does not work
// function classDecorator<TFunction extends Function>(target: TFunction): TFunction | void  {
//   console.log(target)
//   return class extends target {}
// }

@classDecorator
class User {
  constructor(public name: string) {}
}

// Output:
//   [LOG]: class User {
//      constructor(name) {
//        this.name = name;
//      }
//    }

Open example in Playground

Limitations

There is a limitation of modifying the class using a class decorator, which you should be aware of:

TypeScript supports the runtime semantics of the decorator proposal, but does not currently track changes to the shape of the target. Adding or removing methods and properties, for example, will not be tracked by the type system.

You can modify the class, but it's type will not be changed. Open the examples in the next section in the Playground to get an idea of what that means.

There is an ongoing open issue (since 2015) in the TypeScript repo regarding that limitation.

There is a workaround using interface merging, but having to do that somehow misses the point of using the decorator in the first place.

function printable <T extends { new (...args: any[]): {} }>(constructor: T) {
  return class extends constructor {
    print() {
      console.log(constructor.name)
    }
  }
}

// workaround to fix typing limitation
// now print() exists on User
interface User {
  print: () => void;
}

@printable
class User {
  constructor(public name: string) {}
}

const jannik = new User("Jannik");
console.log(jannik.name)
jannik.print() // without workaround: Property 'print' does not exist on type 'User'.

// Output:
//   [LOG]: "Jannik"
//   [LOG]: "User"

Open example in Playground

Examples

Finally, some examples to get an idea of what you can do. There are very few limitations of what you can do since you essentially could just replace the whole class.

Add properties

The following example shows how to add additional attributes to the class and modifying them by passing a function to the decorator factory (see TypeScript decorators basics post for the concept of decorator factories).

interface Entity {
  id: string | number;
  created: Date;
}

function Entity(generateId: () => string | number) {
  return function <T extends { new (...args: any[]): {} }>(constructor: T) {
    return class extends constructor implements Entity {
      id = generateId();
      created = new Date();
    }
  }
}

@Entity(Math.random)
class User {
  constructor(public name: string) {}
}

const jannik = new User("Jannik");
console.log(jannik.id)
console.log(jannik.created)

// Output:
//   [LOG]: 0.48790990206152396
//   [LOG]: Date: "2021-01-23T10:36:12.914Z"

Open example in Playground

This can be quite handy for entities, which you want to store somewhere. You can pass the method to generate the entities id and the created timestamp will automatically be set. You could also extend these example for example by passing a function to format the timestamp.

Prevent modifications of a class

In this example we use Object.seal() on the constructor itself and on its prototype in order to prevent adding/removing properties and make existing properties non-configurable. This could be handy for (parts of) libraries, which should be modified.

function sealed<T extends { new (...args: any[]): {} }>(constructor: T) {
  Object.seal(constructor);
  Object.seal(constructor.prototype);
}

@sealed
class User {
  constructor(public name: string) {}
}

User.prototype.isAdmin = true; // changing the prototype

const jannik = new User("Jannik");
console.log(jannik.isAdmin) // without @sealed -> true

Open example in Playground

Dependency Injection

An advanced usage of class decorators (in synergy with parameter decorators) would be Dependency Injection (DI). This concept is heavily used by frameworks like Angular and NestJS. I will provide a minimal working example. Hopefully you get an idea of the overall concept after that.

DI can be achieved by three steps:

  1. Register an instance of a class that should be injectable in other classes in a Container (also called Registry)
  2. Use a parameter decorator to mark the classes to be injected (here: @inject(); commonly done in the constructor of that class, called constructor based injection).
  3. Use a class decorator (here: @injectionTarget) for a class that should be the target of injections.

The following example shows the UserRepository being injected into the UserService. The created instance of UserService has access to an instance of UserRepository without having a repository passed to its constructor (it has been injected). You can find the explanation as comments in the code.

class Container {
  // holding instances of injectable classes by key
  private static registry: Map<string, any> = new Map();

  static register(key: string, instance: any) {
    if (!Container.registry.has(key)) {
      Container.registry.set(key, instance);
      console.log(`Added ${key} to the registry.`);
    }
  }

  static get(key: string) {
    return Container.registry.get(key)
  }
}

// in order to know which parameters of the constructor (index) should be injected (identified by key)
interface Injection {
  index: number;
  key: string;
}

// add to class which has constructor paramteters marked with @inject()
function injectionTarget() {
  return function injectionTarget <T extends { new (...args: any[]): {} }>(constructor: T): T | void {
    // replacing the original constructor with a new one that provides the injections from the Container
    return class extends constructor {
      constructor(...args: any[]) {
        // get injections from class; previously created by @inject()
        const injections = (constructor as any).injections as Injection[]
        // get the instances to inject from the Container
        // this implementation does not support args which should not be injected
        const injectedArgs: any[] = injections.map(({key}) => {
          console.log(`Injecting an instance identified by key ${key}`)
          return Container.get(key)
        })
        // call original constructor with injected arguments
        super(...injectedArgs);
      }
    }
  }
}

// mark constructor parameters which should be injected
// this stores the information about the properties which should be injected
function inject(key: string) {
  return function (target: Object, propertyKey: string | symbol, parameterIndex: number) {
    const injection: Injection = { index: parameterIndex, key }
    const existingInjections: Injection[] = (target as any).injections || []
    // create property 'injections' holding all constructor parameters, which should be injected
    Object.defineProperty(target, "injections", {
      enumerable: false,
      configurable: false,
      writable: false,
      value: [...existingInjections, injection]
    })
  }
}

type User = { name: string; }

// example for a class to be injected
class UserRepository {
  findAllUser(): User[] {
    return [{ name: "Jannik" }, { name: "Max" }]
  }
}

@injectionTarget()
class UserService {
  userRepository: UserRepository;

  // an instance of the UserRepository class, identified by key 'UserRepositroy' should be injected
  constructor(@inject("UserRepository") userRepository?: UserRepository) {
    // ensures userRepository exists and no checks for undefined are required throughout the class
    if (!userRepository) throw Error("No UserRepository provided or injected.")
    this.userRepository = userRepository;
  }

  getAllUser(): User[] {
    // access to an instance of UserRepository
    return this.userRepository.findAllUser()
  }
}

// initially register all classes which should be injectable with the Container
Container.register("UserRepository", new UserRepository())

const userService = new UserService()
// userService has access to an instance of UserRepository without having it provided in the constructor
// -> it has been injected!
console.log(userService.getAllUser())

// Output:
//   [LOG]: "Added UserRepository to the registry."
//   [LOG]: "Injecting an instance identified by key UserRepository"
//   [LOG]: [{"name": "Jannik"}, {"name": "Max"}]

Open in Playground

Of course this is a basic example with a lot of missing features, but it showcases the potential of class decorators and the concept of DI quite well.

There a few libraries implementing DI: πŸ”· InversifyJS πŸ”· typedi πŸ”· TSyringe

Wrap Up

Class decorators can be very powerful, because you can change the whole class it is decorating. There is a limitation, because the type of a class changed by a decorator will not reflect that change.

Have you ever written your own class decorators? What class decorators have you used?

Feedback welcome

I'd really appreciate your feedback. What did you (not) like? Why? Please let me know, so I can improve the content.

Did you find this article valuable?

Support Jannik Wempe by becoming a sponsor. Any amount is appreciated!

Β