Zero dependency, type-safe, decorator-free Inversion of Control (IoC) container.
Define your services. You can use classes or factory functions:
- Class constructors can only accept a single argument, which is an object with the dependencies
- Factory functions can only accept a single argument, which is an object with the dependencies
// database.ts
export function openDatabase(): Database {
// ...
}
// user-repo.ts
export function createUserRepo(deps: { db: Database }): UserRepo {
// ...
}
// user-service.ts
export class UserService {
constructor(private readonly deps: { userRepo: UserRepo; db: Database }) {
// ...
}
}Once your services are defined, you can register them on a container:
// main.ts
import { openDatabase } from "./database";
import { createUserRepo } from "./user-repo";
import { UserService } from "./user-service";
import { createIocContainer } from "@aklinker1/zero-ioc";
export const container = createIocContainer()
.register("db", openDatabase)
.register("userRepo", createUserRepo)
.register("userService", UserService);And finally, to get an instance of a service from the container, use resolve:
const userService = container.resolve("userService");You can only call register with a service if you've already registered all of its dependencies. For example, if userRepo depends on db, you must register db in a separate call to register before registering userRepo.
The good news is TypeScript will tell you if you messed this up! If you haven't registered a dependency, you'll get a type error when you try to register the service that depends on it:
Additionally, thanks to this type-safety, TypeScript will also report an error for circular dependencies!
To access an object containing all registered services, you have two options:
container.registrations: Returns a proxy object that resolves services lazily when you access them.const { userRepo, userService } = container.registrations;
container.resolveAll(): Immediately resolves all registered services and returns them as a plain object, no proxy magic. Useful when passing services to a third-party library that doesn't support proxies.const { userRepo, userService } = container.resolveAll();
Sometimes you need to pass additional parameters to a service, like config, that aren't previously registered services.
In this case, use parameterize. Any parameters passed in via the second argument don't need to be registered beforehand!
import { createIocContainer, parameterize } from "@aklinker1/zero-ioc";
const openDatabase = (deps: {
username: string;
password: string;
}): Database => {
// ...
};
const container = createIocContainer().register(
"db",
parameterize(openDatabase, {
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
}),
);createIocContainer uses singletones by default. Once your service has been resolved, it will be cached and the same instance will be returned on subsequent calls.
interface UserRepo {
// ...
}
function createUserRepo() {
return {
// ...
};
}
const container = createIocContainer().register("userRepo", createUserRepo);
const userRepo1 = container.resolve("userRepo");
const userRepo2 = container.resolve("userRepo");
console.log(userRepo1 === userRepo2); // trueA "transient" service is a service that is created every time it is resolved. Use the transient helper to inform the container that your service should be created each time:
import { createIocContainer, transient } from "@aklinker1/zero-ioc";
interface UserRepo {
// ...
}
function createUserRepo(): UserRepo {
console.log("Creating new UserRepo");
return {
// ...
};
}
const container = createIocContainer().register(
"userRepoFactory",
transient(createUserRepo),
);
const userRepo1 = container.resolve("userRepo");
const userRepo2 = container.resolve("userRepo");
console.log(userRepo1 === userRepo2); // falseNote that when using container.resolveAll(), you're resolving all dependencies immediately, and any transient services will be created once.
If you need to create an instance every time, you can either:
- Use the
container.registrationsproxy, which will return a new instance on every access. - Don't use
transient. Instead, register a function that returns your factory function or class:container.register("userRepoFactory", () => createUserRepo); const userRepoFactory = container.resolve("userRepoFactory"); const userRepo1 = userRepoFactory(); const userRepo2 = userRepoFactory(); console.log(userRepo1 === userRepo2); // false
"Scoped" services are services that are created once per "scope". Zero IoC does not currently support creating "scopes", and thus does not support scoped services.
See #4.
If someone has a good proposal that keeps the API simple, leave a comment on the issue. I'd be happy to consider adding support.
This library was heavily inspired by Awilix, a powerful IoC container for JavaScript/TypeScript. While @aklinker1/zero-ioc is intentionally simpler and more opinionated (focusing on singletons, explicit dependencies, and type-safety through TypeScript), Awilix's approach to dependency registration and resolution without decorators was a major influence.
| Feature | Zero IoC | Awilix | InversifyJS | TSyringe | TypeDI |
|---|---|---|---|---|---|
| Decorators | ❌ | ❌ | ✅ | ✅ | ✅ |
Require reflect-metadata |
❌ | ❌ | ✅ | ✅ | ✅ |
| Class-based Services | ✅ | ✅ | ✅ | ✅ | ✅ |
| Factory Function-based Services | ✅ | ✅ | ❌ | ❌ | ❌ |
| Singleton Lifetimes | ✅ | ✅ | ✅ | ✅ | ✅ |
| Transient Lifetimes | ✅ | ✅ | ✅ | ✅ | ✅ |
| Scoped Lifetimes | ❌ see #4 | ✅ | ✅ | ✅ | ✅ |
| Circular Dependency Detection | ✅ via TS | ❌ | 🟡 at runtime | 🟡 at runtime | ❌ |
| Circular Dependency Support | ❌ | 🟡 not recommended | ❌ | ✅ | ❌ |
| End-to-end Type-safety | ✅ | ❌ | ❌ | ❌ | ❌ |
| Async Resolution | 🟡 | ✅ via awilix-manager |
✅ | ❌ | ❌ |
| Module loader | ❌ | ✅ | ❌ | ❌ | ❌ |
| Dependencies (Subdependencies) | 0 | 1 (18) | 3 (6) + 1 | 1 + 1 | 0 + 1 |
| Package Size (Install Size) | 17 kB | 326.6 kB (835.6 kB) | 32.7 kB (873.7 kB) + 241.2 kB | 148.6 kB (182.5 kB) + 241.2 kB | 432.8 kB + 241.2 kB |