DEV Community

Cover image for Clawject: Simplifying Dependency Injection in TypeScript
Artem Korniev
Artem Korniev

Posted on

Clawject: Simplifying Dependency Injection in TypeScript

Outline

Greetings, I'm the creator of Clawject, a dependency injection framework for TypeScript. I'm here to introduce you to the world of effortless and type-safe dependency injection.

Have you ever thought that dependency injection in TypeScript could be easier?

Meet Clawject, the first truly type-safe dependency injection framework for TypeScript.

Visit clawject.com for more detailed API end examples.

You diligently write types, interfaces, generics, and all of that to ensure type safety, but it all comes to an end if you mix up an injection token.

@Injectable()
class CatsService {
  constructor(
    @Inject(InjectionTokens.DogsRepository)
    private catsRepository: Repository<Cat>
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

TypeScript will compile this code without any errors, it will execute, and you'll only find out at runtime that instead of cats, you're getting dogs. Dogs are also great, but in this case, we specifically need cats.

Now, let's define CatsService with Clawject.

class CatsService {
  constructor(
    private catsRepository: Repository<Cat>
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

As you can see, Clawject doesn't require you to attach the @Injectable decorator to the class or specify what exactly you want to inject. Instead, Clawject will take the type of your dependency and find the appropriate implementation at compile time.

If we wanted to avoid attaching the @Injectable decorator to the class without Clawject, you would use something like a Factory providers, but this is quite inconvenient. It requires changes if the class constructor changes and still leaves room for error by mixing up injection tokens. Besides, it's just not elegant.

const InjectionTokens = {
  DogsRepository: Symbol('dogsRepository'),
  CatsRepository: Symbol('catsRepository')
}

class CatsService {
  constructor(
    private catsRepository: Repository<Cat>
  ) {}
}

@Module({
  providers: [
    {
      provide: InjectionTokens.DogsRepository,
      useClass: Repository
    },
    {
      provide: InjectionTokens.CatsRepository,
      useClass: Repository
    },
    {
      provide: CatsService,
      useFactory: (catsRepository: Repository<Cat>) =>
        new CatsService(catsRepository),
              /*Oops, wrong injection token...*/
      inject: [InjectionTokens.DogsRepository]
    }
  ],
})
export class Application {}
Enter fullscreen mode Exit fullscreen mode

Now, let's take a look at how you can create a CatsService with Clawject.

class CatsService {
  constructor(
    private catsRepository: Repository<Cat>
  ) {}
}

@ClawjectApplication
class Application {
  dogsRepository = Bean(Repository<Dog>);
  catsRepository = Bean(Repository<Cat>);

  catsService = Bean(CatsService);
}
Enter fullscreen mode Exit fullscreen mode

And that's it! ✨

Clawject will resolve all dependencies based on types and notify you of errors such as missing dependencies, circular dependencies, and more, at compile time and directly in your favorite IDE!

Clawject in-editor errors demonstration

Unleash the power of polymorphism with Clawject

Clawject works great with classes, interfaces, types, generics, and type inheritance, allowing you to describe dependencies very flexibly and simply.

Let's declare an interface ReadOnlyRepository<T>, interface Repository<T>, and a class HttpRepository<T> that implements both of these interfaces:

interface ReadOnlyRepository<T> { /*...*/ }

interface Repository<T> extends ReadOnlyRepository<T> { /*...*/ }

class HttpRepository<T> implements Repository<T> { /*...*/ }
Enter fullscreen mode Exit fullscreen mode

We've created a polymorphic class and interfaces that allow reading and writing entities with the type <T>.

Now, let's declare the following services: ReadCatsService and WriteCatsService.

class ReadCatsService {
  constructor(
    private repository: ReadOnlyRepository<Cat>
  ) {}
}

class WriteCatsService {
  constructor(
    private repository: Repository<Cat>
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we simply state that we need dependencies of types ReadOnlyRepository<Cat> and Repository<Cat>, and we don't care about the specific implementation that will be injected.

Now, let's declare the Application class and our Beans.

@ClawjectApplication
class Application {
  httpCatsRepository = Bean(HttpRepository<Cat>);

  readCatsService = Bean(ReadCatsService);
  writeCatsService = Bean(WriteCatsService);
}
Enter fullscreen mode Exit fullscreen mode

As you can see, Clawject understands that the class HttpRepository<Cat> implements the interfaces ReadOnlyRepository<Cat> and Repository<Cat>, and will inject the httpCatsRepository bean instance as a dependency in both services.

If we were to do something similar with a more classical library, we would have to write a lot of boilerplate and non-type-safe code.

const InjectionTokens = {
  ReadOnlyCatsRepository: Symbol('ReadOnlyCatsRepository'),
  CatsRepository: Symbol('CatsRepository'),
  HttpCatsRepository: Symbol('HttpCatsRepository'),
}

@Injectable()
class ReadCatsService {
  constructor(
      @Inject(InjectionTokens.ReadOnlyCatsRepository)
      private repository: ReadOnlyRepository<Cat>
  ) {}
}

@Injectable()
class WriteCatsService {
  constructor(
      @Inject(InjectionTokens.CatsRepository)
      private repository: Repository<Cat>
  ) {}
}

@Module({
  providers: [
    {
      useClass: HttpRepository,
      provide: InjectionTokens.HttpCatsRepository,
    },
    {
      provide: InjectionTokens.ReadOnlyCatsRepository,
      useExisting: InjectionTokens.HttpCatsRepository,
    },
    {
      provide: InjectionTokens.CatsRepository,
      useExisting: InjectionTokens.HttpCatsRepository,
    },
    ReadCatsService,
    WriteCatsService,
  ],
})
class Application {}
Enter fullscreen mode Exit fullscreen mode

Clean domain objects

The philosophy of Clawject revolves around the idea that dependency injection should be an external architectural layer, while all domain objects should describe their dependencies without any framework references.

Clawject allows you to extract absolutely all DI-related things from your classes, simplifying your code and reducing the probability of mistakes. Because Clawject is a very external component, you can work with any classes from external libraries just as easily as with your own.

Let's imagine we have an npm package called data-access.

/* node_modules/data-access/repositories.d.ts */

export interface Repository<T> { /*...*/ }
export declare class HttpRepository<T> implements Repository<T> {
  private readonly baseUrl;
  constructor(baseUrl: string);
}
Enter fullscreen mode Exit fullscreen mode

Now, let's declare the CatsService that uses the repository from data-access package.

/* src/cat/CatsService.ts */

import { Repository } from 'data-access';
import { Cat } from './models/Cat';

class CatsService {
  constructor(
    private repository: Repository<Cat>
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

Now, let's declare beans for our classes.

/* src/Application.ts */

import { HttpRepository } from 'data-access';
import { Cat } from './cat/models/Cat';
import { CatsService } from './cat/CatsService';

@ClawjectApplication
class Application {
  @Bean catsApiBaseUrl = '/api/cat';

  httpCatsRepository = Bean(HttpRepository<Cat>);
  catsService = Bean(CatsService);
}
Enter fullscreen mode Exit fullscreen mode

As you can see, the classes remain clean and literally know nothing about being part of a dependency injection container. Moreover, you don't need to manually write factory functions to keep your classes clean and framework-independent.

Split container by features

Clawject allows you to break down parts of the container into separate classes and even share them via npm packages.

Let's declare the CatsConfiguration class, which will contain beans related only to cats.

@Configuration
export class CatsConfiguration {
  @Bean apiBaseUrl = '/api/cat';

  httpCatsRepository = Bean(HttpRepository<Cat>);
  catsService = Bean(CatsService);
}
Enter fullscreen mode Exit fullscreen mode

Now we can import the configuration classes into our Application class or other configuration classes.

import { CatsConfiguration } from './cat/CatsConfiguration';
import { DogsConfiguration } from './dog/DogsConfiguration';
import { BirdsConfiguration } from './bird/BirdsConfiguration';

@ClawjectApplication
class Application {
  catsConfiguration = Import(CatsConfiguration);
  dogsConfiguration = Import(DogsConfiguration);
  birdsConfiguration = Import(BirdsConfiguration);
}
Enter fullscreen mode Exit fullscreen mode

Clawject will assemble the entire container, instantiate all classes with the necessary dependencies, and inform you in a very developer-friendly manner if anything is missing.

Thanks for reading this article. If you're interested, visit Clawject's website for more examples and learn how to install and use it in your project.

Top comments (1)

Collapse
 
sonya569 profile image
Sonya

Amazing! 🔥🔥🔥