Bloat-free and magical IoC-container for Typescript based on code-generation
For genioc
to work, there is additional pre-processing step before running your app.
genioc
compiles your Typescript project, parses dependencies of each class constructor, and generates a specific-for-you-project file which exports a ready-to-use IoC container for you (example here).
Please note, that it's not the same as constructor signature parsing as for example awilix
does - with genioc
you may use whatever names or order of constructor arguments you want - only the type of them matters.
Example usage:
//userService.ts
export class UserService {
constructor(
private readonly userRepository: IUserRepository
) {
}
}
//index.ts
import container from './genioc.autogenerated.ts'
container.bind("IUserRepository", SqlUserRepository);
container.bindSelf(UserService);
const userService: UserService = container.get(UserService);
userService.doStuff(); //at this point, userService would be a valid instance with SqlUserRepository implementation injected
Because of current Typescript limitations, you must pass interface names as strings to bind them. However, genioc
creates a union special type for that to guarantee that you will enter a valid interface name.
In Java or PHP, because of powerful built-in reflection features, you can inject dependencies into constructors just by specifying correct type:
class UserService {
private final IUserRepository userRepo;
public UserService(IUserRepository userRepo) {
this.userRepo = userRepo;
}
}
in current TS/JS solutions, you will have either to:
- use tokens with decorators (via old
reflect-metadata
):
const UserRepositoryIdentifier = Symbol.for('UserRepository')
class UserService {
constructor(
@inject(UserRepositoryIdentifier) private readonly userRepo: IUserRepository
) {
}
}
-
in such solutions as
awilix
withInjectMode.CLASSIC
or for example with AngularJS injector, which just stringify function definition and parse argument names, you must preserve the original names of the arguments in the code, and therefore cannot minify the code. -
destruct object in the constructor which contains all dependencies, which basically is a violation of IoC principle and resolves to Service locator pattern
class UserService {
constructor(opts) {
this.emailService = opts.emailService // this is not true DI!
this.logger = opts.logger
}
}
And as you might see, all these solutions are kinda hacky and not as magical and easy-to-use as Java one for example.
genioc
on other hand, allows you to write Java-way injection:
class UserService {
constructor(
private readonly userRepo: IUserRepository
) {
}
}
Yes, that is really that simple. Just do not forget to bind the actual IUserRepository
to an implementation.
However, there is one catch - you must run genioc
script everytime you add a new dependency in the code. And to find out why, now we come to the mechanism how genioc
works.
Due genioc
being not quite a default library, you must alter your development workflow a little:
- First you define interfaces of your dependencies such as
IUserRepository
,IMailer
orILogger
, etc.. - Then you create actual implementations for them:
SqlUserRepository
,MailchimpMailer
,ConsoleLogger
- Then you pass interfaces for these dependencies to classes where you need them, for example:
class MyService {
constructor(
private readonly mailer: IMailer,
private readonly userRepo: IUserRepository
) {
}
public resetPassword(email: string) {
const user = await this.useRepo.findByEmail(email);
await mailer.sendResetPasswordEmail(user.email, user.resetToken);
}
}
- You run
genioc
to generate IoC container code for you - You bind interfaces to their dependencies:
import container from './genioc.autogenerated.ts'
container.bind('ILogger', ConsoleLogger);
container.bind('IMailer', MailchimpMailer);
container.bind('IUserRepository', SqlUserRepository);
- Done. You can now run your app
genioc
container is default-exported from generated file, so you should use the following import statement as a starting point:
import container from './genioc.autogenerated.ts'
All methods support either classes, strings or Symbols as tokens.
Following methods are supported:
Binds token
to class classConstructor
.
interface IDog {
bark(): string;
}
class Doggy implements IDog {
public bark() {
return "Woof!";
}
}
//...
container.bind("IDog", Doggy);
Binds given class to itself, for injecting in the following manner:
container.bindSelf(MyClass);
//...
class MyClassUser {
constructor(
private readonly myClass: MyClass
) {
}
}
Binds token
to given constant value. You can use optional generic type T
to make sure the correct value type is passed. Useful for binding static objects such as configs or messages collections.
interface IAppConfig {
niceDog: boolean;
}
container.bindValue<IAppConfig>("IAppConfig", {
niceDog: true
});
Binds token
to a factory function, which will be called every time you try to obtain an instance by this token.
interface ICurentTimeProvider {
time: number;
}
container.bindFactory<ICurentTimeProvider>("ICurentTimeProvider", () => {
return {
time: Date.now()
}
});
Use to obtain instance of binding by token
.
By default, it uses lazy resolving, which means that if instance does not exist, it will be resolved in-place and then cached for future use.
Will throw an error if you are trying to query token which has no assigned binding.
You should use generic argument T
to explicitly define the return type of the function.
const app: Application = container.get<Application>("IApplication");
app.run();
genioc [--dev] [--watch | -w] [--output | -o <output path>] [project directory]
--dev
- only used when testing genioc example itself--watch | -w
- starts in watch mode, which will automatically rebuild your IoC container when source code is changed--output | -o <path>
- specify name/path for output container file, relative to project directoryproject directory
- path to your project with tsconfig.json. If not used, it will use current process working directory.
Run this tool to re-build dependency tree for your project. You can run this before each build or just use it in watch mode.
genioc
container is capable of resolving circular dependencies automatically, you can check the example. No additional decorators or wrappers needed.
The main way of injection a dependency is using the constructor injection, and it should be the preferred one.
You can resolve them also in runtime by using container.get
method, however, again, this is not dependency injection in classical form then.
Nikita Kogut
MIT (see LICENSE file)