TypeScript 51 🔷 Type-Safe Dependency Injection
Dependency injection is the practice of passing a class its dependencies rather than letting it construct them itself. Instead of a UserService calling new Database() internally, it receives a Database instance as a constructor argument. The benefit is not aesthetic — it is that the dependency can be swapped for a test double, a different implementation, or a configuration-driven variant without touching the class. In TypeScript, dependency injection has no built-in framework the way Angular or NestJS provide one. What TypeScript has is a type system expressive enough to encode the wiring rules: a token identifies each dependency, a container maps tokens to factories, and the types ensure that what comes out of the container matches what the consumer expects. This chapter covers the building blocks — tokens, registries, lifetime scopes, and the patterns that make injection type-safe without a runtime framework.
Key point: A type-safe DI container has three parts: a token that uniquely identifies a dependency, a registry that maps tokens to factories, and a resolver that returns the constructed instance with the correct type. The token is what makes the system type-safe — it carries the type of the value it produces, so the registry can be typed as a map from token to factory and the resolver can infer the return type from the token. Lifetimes — singleton, transient, scoped — are a separate concern layered on top, and getting them wrong produces the subtle bugs that DI is supposed to prevent.
Why dependency injection matters
The case for DI is not that it is a pattern. It is that it makes code testable and replaceable in ways that hardcoded dependencies do not.
Testability. A class that constructs its own dependencies cannot be tested in isolation. The test either uses the real dependency — which may hit a database, a network, or a clock — or it uses a mock that requires the class to expose a seam. With DI, the test passes a mock directly, and the class never knows the difference.
Replaceability. The same class can be used with a production database, an in-memory store, or a test double, depending on what is injected. This is what makes configuration-driven behavior possible without conditionals inside the class.
Explicit dependencies. A constructor that takes three arguments documents what the class needs. A class that reaches into a global or constructs its own dependencies hides its requirements, which makes it harder to reason about and harder to change.
Why TypeScript’s role matters. In a dynamically typed language, DI is a runtime concern — the container is a map, and mistakes are discovered when a dependency is missing or the wrong type. In TypeScript, the token can carry the type, and the container can be typed so that the wrong token or the wrong factory is a compile error. This is the difference between DI that works and DI that is checked.
Why frameworks exist. Angular, NestJS, InversifyJS, and tsyringe all provide DI containers with decorators, metadata, and automatic wiring. They solve the problem at a higher level, but they also add runtime machinery and a dependency on reflection metadata. For a small project or a library, a hand-written container is often enough, and it is fully type-safe without decorators.
Why decorator-based DI has a cost. The decorators emit metadata using
reflect-metadata, which requiresemitDecoratorMetadataandexperimentalDecoratorsintsconfig.json. The metadata is used at runtime to figure out what to inject. This works, but it is opaque — the wiring is implicit, and a missing provider is a runtime error rather than a compile error. A token-based container makes the wiring explicit and the errors compile-time.
Tokens: the identity of a dependency
A token is a value that uniquely identifies a dependency. It can be a string, a symbol, or a class. The choice determines how collisions are avoided and how the type is carried.
const DATABASE = Symbol('Database');
const LOGGER = Symbol('Logger');
const CLOCK = Symbol('Clock');
A Symbol is the safest token because each symbol is unique — no two symbols are ever equal, even if they have the same description. A string token like 'Database' works but can collide if two libraries use the same string. A class token uses the class itself as the key, which is how Angular and Inversify work, but it limits one token per class.
Why symbols are the default in a hand-written container. They are unique, they are cheap, and they can be given a description for debugging. The description appears in error messages and debug output, which makes the token self-documenting without affecting its identity.
Why the token must carry the type. A bare symbol is just a symbol — it has no type information. To make the container type-safe, the token must be associated with the type of the value it produces. This is done with a branded token or a typed token interface.
interface Token<T> {
readonly key: symbol;
readonly description: string;
// phantom — never used at runtime
readonly __type?: T;
}
function createToken<T>(description: string): Token<T> {
return { key: Symbol(description), description };
}
The __type field is a phantom type — it exists only in the type system and is never assigned. It lets the token’s type parameter T be inferred from the token itself, which is what makes the registry and resolver type-safe.
Why the phantom type is necessary. TypeScript has no way to attach a type to a value without a field that carries it. The __type field is the carrier, and it is never read at runtime. It costs nothing and enables the type safety. The technique is the same one used for branded types (TypeScript 42).
Why a single token type is better than many. With Token<T>, every dependency has the same shape and the registry can be a single map from Token<unknown> to factories. The type parameter is what varies. This uniformity is what makes the container generic and the resolver inferable.
The registry and the resolver
The registry maps tokens to factories, and the resolver uses the registry to construct instances. The two together are the container.
type Factory<T> = (container: Container) => T;
class Container {
private readonly factories = new Map<symbol, Factory<unknown>>();
private readonly instances = new Map<symbol, unknown>();
register<T>(token: Token<T>, factory: Factory<T>): void {
this.factories.set(token.key, factory as Factory<unknown>);
}
resolve<T>(token: Token<T>): T {
const existing = this.instances.get(token.key);
if (existing !== undefined) return existing as T;
const factory = this.factories.get(token.key);
if (!factory) {
throw new Error(`No provider for ${token.description}`);
}
const instance = factory(this);
this.instances.set(token.key, instance);
return instance as T;
}
}
The register method takes a token and a factory, and stores the factory under the token’s key. The resolve method looks up the factory, calls it with the container (so the factory can resolve its own dependencies), and caches the result. The type parameter T flows from the token to the return type, so container.resolve(DATABASE) has the type of the database.
Why the factory receives the container. A factory often needs to construct its dependency from other dependencies. Passing the container lets the factory call container.resolve for each of them. This is how the dependency graph is wired — each factory describes its own dependencies, and the container resolves them recursively.
container.register(DATABASE, () => new PostgresDatabase());
container.register(LOGGER, () => new ConsoleLogger());
container.register(USER_SERVICE, (c) =>
new UserService(c.resolve(DATABASE), c.resolve(LOGGER)),
);
The USER_SERVICE factory resolves the database and the logger from the container and passes them to the UserService constructor. The dependencies are explicit, and the types are checked — c.resolve(DATABASE) returns the database type, which must match the constructor’s first parameter.
Why the instances map is the singleton cache. The first resolve call constructs the instance and caches it. Subsequent calls return the cached instance. This makes the default lifetime a singleton. Other lifetimes are layered on top, which the next section covers.
Why the cast in register is necessary. The Map is typed as Map<symbol, Factory<unknown>>, and the incoming factory is Factory<T>. The cast to Factory<unknown> is safe because the token’s key is the only thing stored — the type parameter is not. The cast is localized to the register method, and callers never see it.
Why the missing-provider error is a runtime error. The container cannot know at compile time which tokens have been registered, because registration happens at runtime. A resolve for an unregistered token fails at runtime with a descriptive error. This is the one place where the type system cannot help, and the error message is what makes it diagnosable.
Lifetimes
A lifetime determines how long an instance lives and how many times it is constructed. Three lifetimes cover almost every case.
Singleton. One instance per container. The first resolve constructs it, and every subsequent call returns the same instance. This is the default in the code above, and it is the right choice for stateless services, configuration, and connections.
Transient. A new instance per resolve. Nothing is cached, and the factory runs every time. This is right for stateful objects that should not be shared — a request context, a transaction, a temporary buffer.
Scoped. One instance per scope. A scope is a child container that shares the parent’s registrations but has its own instance cache. This is right for request-scoped services in a server, where each request gets its own instance but the application-level services are shared.
class Container {
// ... register, resolve as before
createScope(): Container {
const scope = new Container();
// inherit factories, but start with a fresh instance cache
for (const [key, factory] of this.factories) {
scope.factories.set(key, factory);
}
return scope;
}
}
The scope is a new container with the same factories and an empty instance cache. Resolving from the scope constructs new instances for scoped and transient dependencies, and the parent’s singletons are not visible from the scope unless they are registered in the scope too.
Why lifetimes matter. Getting the lifetime wrong is the most common source of DI bugs. A stateful service registered as a singleton is shared across requests, and state leaks between them. A stateless service registered as transient is constructed repeatedly, which is wasteful. A scoped service registered as a singleton becomes a global.
Why the default should be singleton. Most dependencies are stateless — a logger, a configuration, a database connection pool. Singleton is the right default for those, and it is the cheapest. Transient and scoped are opt-in, used when the dependency has state that must not be shared.
Why lifetimes should be explicit. A registration that does not state its lifetime is ambiguous. Making the lifetime an argument to register forces the decision and documents it.
type Lifetime = 'singleton' | 'transient' | 'scoped';
register<T>(token: Token<T>, factory: Factory<T>, lifetime: Lifetime = 'singleton'): void {
// ...
}
The default is singleton, and the caller can override. The lifetime is stored alongside the factory, and the resolver consults it to decide whether to cache.
Circular dependencies
A circular dependency is when A depends on B and B depends on A. The container cannot construct either one first, and a naive resolver recurses infinitely.
Why the cycle is a design problem. Circular dependencies are almost always a sign that the responsibilities are split wrong. Two services that need each other are usually one service, or they need a third that both depend on. The DI container surfaces the cycle, but the fix is a design change, not a container feature.
How the container detects the cycle. The resolver tracks the tokens currently being resolved, and if it encounters a token already in the set, it throws. This turns an infinite loop into an error.
private readonly resolving = new Set<symbol>();
resolve<T>(token: Token<T>): T {
if (this.resolving.has(token.key)) {
throw new Error(`Circular dependency: ${token.description}`);
}
this.resolving.add(token.key);
try {
// ... resolve
} finally {
this.resolving.delete(token.key);
}
}
The resolving set is a stack of tokens in progress. If a token appears twice, the cycle is detected and reported with the token’s description. The finally block ensures the token is removed even if the resolution fails.
Why lazy resolution is the escape hatch. A cycle can sometimes be broken by resolving one of the dependencies lazily — passing a function that resolves the dependency when it is first used, rather than the instance itself.
interface Lazy<T> {
get(): T;
}
container.register(A, (c) => {
const lazyB = { get: () => c.resolve(B) } as Lazy<B>;
return new A(lazyB);
});
The Lazy<B> is a wrapper that defers the resolution until get is called. This breaks the cycle at construction time, at the cost of a small indirection. It is a tool of last resort, and the design problem usually remains.
Why the fix is usually a refactor. When two services need each other, one of them should not need the other. Extracting the shared logic into a third service that both depend on is the standard fix. The container’s job is to report the cycle; the developer’s job is to remove it.
The typed container
The full container with types, lifetimes, scopes, and cycle detection is about 100 lines. It is worth seeing together.
interface Token<T> {
readonly key: symbol;
readonly description: string;
readonly __type?: T;
}
function createToken<T>(description: string): Token<T> {
return { key: Symbol(description), description };
}
type Factory<T> = (container: Container) => T;
type Lifetime = 'singleton' | 'transient' | 'scoped';
interface Registration<T> {
factory: Factory<T>;
lifetime: Lifetime;
}
class Container {
private readonly registrations = new Map<symbol, Registration<unknown>>();
private readonly instances = new Map<symbol, unknown>();
private readonly resolving = new Set<symbol>();
register<T>(
token: Token<T>,
factory: Factory<T>,
lifetime: Lifetime = 'singleton',
): void {
this.registrations.set(token.key, {
factory: factory as Factory<unknown>,
lifetime,
});
}
resolve<T>(token: Token<T>): T {
const registration = this.registrations.get(token.key);
if (!registration) {
throw new Error(`No provider for ${token.description}`);
}
if (registration.lifetime === 'singleton') {
const cached = this.instances.get(token.key);
if (cached !== undefined) return cached as T;
}
if (this.resolving.has(token.key)) {
throw new Error(`Circular dependency: ${token.description}`);
}
this.resolving.add(token.key);
try {
const instance = registration.factory(this);
if (registration.lifetime === 'singleton') {
this.instances.set(token.key, instance);
}
return instance as T;
} finally {
this.resolving.delete(token.key);
}
}
createScope(): Container {
const scope = new Container();
for (const [key, registration] of this.registrations) {
scope.registrations.set(key, registration);
}
return scope;
}
}
The container is a single class with three maps and a set. The registrations map holds the factories and lifetimes. The instances map holds the cached singletons. The resolving set detects cycles. The resolve method checks the cache, checks for cycles, runs the factory, and caches if the lifetime is singleton. The createScope method produces a child container that shares the registrations and has a fresh instance cache.
Why the container is a class and not a set of functions. The container holds state — the registrations, the instances, the resolving set — and the methods operate on that state. A class is the natural shape. A functional version is possible, but the class is clearer.
Why the token interface uses a phantom type. The __type field is never assigned and never read. It exists so that Token<T> carries T in a way TypeScript can infer. Without it, the token would be untyped and the container would not be type-safe.
Why the factory takes the container. The factory needs to resolve its own dependencies, and the container is the object that can do that. Passing it as an argument is the standard pattern.
Why the lifetime is stored with the factory. The lifetime is a property of the registration, not of the token or the factory alone. Storing them together keeps the registration atomic.
Complete Example Session
// ============================================
// PART 1: TOKENS
// ============================================
interface Token<T> {
readonly key: symbol;
readonly description: string;
readonly __type?: T;
}
function createToken<T>(description: string): Token<T> {
return { key: Symbol(description), description };
}
const DATABASE = createToken<Database>('Database');
const LOGGER = createToken<Logger>('Logger');
const USER_SERVICE = createToken<UserService>('UserService');
// ============================================
// PART 2: SERVICES
// ============================================
interface Database {
query(sql: string): Promise<unknown[]>;
}
class PostgresDatabase implements Database {
async query(sql: string): Promise<unknown[]> {
return [];
}
}
interface Logger {
log(message: string): void;
}
class ConsoleLogger implements Logger {
log(message: string): void {
console.log(message);
}
}
class UserService {
constructor(
private readonly db: Database,
private readonly logger: Logger,
) {}
async findUser(id: string): Promise<unknown> {
this.logger.log(`Finding user ${id}`);
const rows = await this.db.query(`SELECT * FROM users WHERE id = '${id}'`);
return rows[0];
}
}
// ============================================
// PART 3: REGISTRATION
// ============================================
const container = new Container();
container.register(DATABASE, () => new PostgresDatabase());
container.register(LOGGER, () => new ConsoleLogger());
container.register(USER_SERVICE, (c) =>
new UserService(c.resolve(DATABASE), c.resolve(LOGGER)),
);
// ============================================
// PART 4: RESOLUTION
// ============================================
const userService = container.resolve(USER_SERVICE);
// userService is typed as UserService
// ============================================
// PART 5: LIFETIMES
// ============================================
container.register(DATABASE, () => new PostgresDatabase(), 'singleton');
container.register(LOGGER, () => new ConsoleLogger(), 'transient');
container.register(REQUEST_CONTEXT, () => new RequestContext(), 'scoped');
// ============================================
// PART 6: SCOPES
// ============================================
const scope1 = container.createScope();
const scope2 = container.createScope();
const ctx1 = scope1.resolve(REQUEST_CONTEXT);
const ctx2 = scope2.resolve(REQUEST_CONTEXT);
// ctx1 !== ctx2 — each scope has its own instance
const db1 = scope1.resolve(DATABASE);
const db2 = scope2.resolve(DATABASE);
// db1 === db2 — singleton shared across scopes
// ============================================
// PART 7: CIRCULAR DEPENDENCY DETECTION
// ============================================
// container.register(A, (c) => new A(c.resolve(B)));
// container.register(B, (c) => new B(c.resolve(A)));
// container.resolve(A);
// Error: Circular dependency: A
// ============================================
// PART 8: TESTING
// ============================================
class InMemoryDatabase implements Database {
async query(_sql: string): Promise<unknown[]> {
return [{ id: '1', name: 'Test' }];
}
}
const testContainer = new Container();
testContainer.register(DATABASE, () => new InMemoryDatabase());
testContainer.register(LOGGER, () => new ConsoleLogger());
testContainer.register(USER_SERVICE, (c) =>
new UserService(c.resolve(DATABASE), c.resolve(LOGGER)),
);
const service = testContainer.resolve(USER_SERVICE);
// Uses the in-memory database — no real database needed
// ============================================
// PART 9: MISSING PROVIDER
// ============================================
// container.resolve(USER_SERVICE); // if not registered
// Error: No provider for UserService
The nine parts cover tokens, services, registration, resolution, lifetimes, scopes, cycle detection, testing, and the missing-provider case.
Quick Reference
Core Concepts
| Concept | Purpose |
|---|---|
| Token | Unique identifier for a dependency |
| Factory | Function that constructs the instance |
| Registry | Map from token to factory |
| Resolver | Constructs instances on demand |
| Lifetime | How long an instance lives |
| Scope | Child container with its own cache |
Lifetimes
| Lifetime | Instances | Use |
|---|---|---|
| Singleton | One per container | Stateless services |
| Transient | New per resolve | Stateful, non-shared |
| Scoped | One per scope | Request-scoped |
Token Kinds
| Kind | Uniqueness | Use |
|---|---|---|
Symbol | Always unique | Hand-written containers |
string | Can collide | Simple cases |
| Class | One per class | Decorator-based containers |
Container Methods
| Method | Purpose |
|---|---|
register(token, factory, lifetime?) | Add a provider |
resolve(token) | Get an instance |
createScope() | Make a child container |
Common Patterns
| Pattern | Example |
|---|---|
| Register | container.register(DB, () => new Postgres()) |
| Resolve with deps | container.register(SVC, (c) => new Svc(c.resolve(DB))) |
| Test double | container.register(DB, () => new FakeDb()) |
| Scope | container.createScope() |
| Lazy | { get: () => c.resolve(B) } |
Best Practices
✅ Do This:
// Use symbol tokens for uniqueness
const DATABASE = createToken<Database>('Database'); // ✅
// Make dependencies explicit in the constructor
class UserService {
constructor(private db: Database, private logger: Logger) {}
} // ✅
// Default to singleton
container.register(DATABASE, () => new PostgresDatabase()); // ✅
// Use scopes for request-scoped state
const scope = container.createScope(); // ✅
// Detect cycles with a resolving set
if (this.resolving.has(token.key)) throw new Error('Circular'); // ✅
// Inject test doubles in tests
testContainer.register(DATABASE, () => new InMemoryDatabase()); // ✅
❌ Don’t Do This:
// Don't construct dependencies inside the class
class UserService {
private db = new PostgresDatabase(); // untestable // ⚠️
}
// Don't use string tokens in a large codebase
container.register('Database', ...); // collisions // ⚠️
// Don't make everything transient
container.register(DATABASE, () => new PostgresDatabase(), 'transient'); // ⚠️
// Don't share a scoped service as a singleton
// State leaks between requests // ⚠️
// Don't ignore a circular dependency error
// It is a design problem, not a container bug // ⚠️
// Don't resolve in the constructor of the container
// The container is not ready until registration is complete // ⚠️
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
| Untyped token | No type inference | Use Token<T> with phantom type |
| String token collision | Wrong instance | Use Symbol |
| Wrong lifetime | State leaks or waste | Match lifetime to state |
| Circular dependency | Infinite recursion | Detect and refactor |
| Missing provider | Runtime error | Descriptive error message |
| Singleton stateful service | Shared state | Use transient or scoped |
| Resolving before registering | Missing provider | Register first |
| Scope not inheriting factories | Empty container | Copy registrations |
Real-World Examples
1. Database token
const DATABASE = createToken<Database>('Database');
2. Registering a service
container.register(LOGGER, () => new ConsoleLogger());
3. Registering with dependencies
container.register(USER_SERVICE, (c) =>
new UserService(c.resolve(DATABASE), c.resolve(LOGGER)),
);
4. Resolving
const service = container.resolve(USER_SERVICE);
5. Test double
testContainer.register(DATABASE, () => new InMemoryDatabase());
6. Scoped service
container.register(REQUEST_CONTEXT, () => new RequestContext(), 'scoped');
7. Scope per request
const scope = container.createScope();
8. Lazy dependency
const lazy = { get: () => container.resolve(SERVICE) };
9. Singleton configuration
container.register(CONFIG, () => loadConfig(), 'singleton');
10. Transient factory
container.register(BUFFER, () => new Buffer(), 'transient');
Visual: The Container
┌──────────────────────────────────────────────────────────┐
│ TOKEN FACTORY │
│ │ │ │
│ │ register │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ registrations: Map<symbol, Registration>│ │
│ │ DATABASE.key → () => new Postgres() │ │
│ │ LOGGER.key → () => new Console() │ │
│ │ USER.key → (c) => new User( │ │
│ │ c.resolve(DATABASE),│ │
│ │ c.resolve(LOGGER)) │ │
│ └─────────────────────────────────────────┘ │
│ │
│ resolve(USER_SERVICE) │
│ │ │
│ ▼ │
│ factory(container) │
│ │ │
│ ├── resolve(DATABASE) ──► PostgresDatabase │
│ ├── resolve(LOGGER) ──► ConsoleLogger │
│ └── new UserService(db, logger) │
│ │
│ Returns a UserService with its dependencies wired. │
│ │
└──────────────────────────────────────────────────────────┘
Visual: Lifetimes
┌──────────────────────────────────────────────────────────┐
│ SINGLETON │
│ │
│ resolve(DB) ──► constructs ──► caches │
│ resolve(DB) ──► returns cache │
│ resolve(DB) ──► returns cache │
│ │
│ One instance for the container's lifetime. │
│ │
├──────────────────────────────────────────────────────────┤
│ TRANSIENT │
│ │
│ resolve(BUF) ──► constructs ──► returns │
│ resolve(BUF) ──► constructs ──► returns │
│ resolve(BUF) ──► constructs ──► returns │
│ │
│ New instance every time. │
│ │
├──────────────────────────────────────────────────────────┤
│ SCOPED │
│ │
│ scope1.resolve(CTX) ──► constructs ──► cached in scope1 │
│ scope1.resolve(CTX) ──► returns scope1 cache │
│ scope2.resolve(CTX) ──► constructs ──► cached in scope2 │
│ │
│ One instance per scope. │
│ │
└──────────────────────────────────────────────────────────┘
Visual: Circular Dependency Detection
┌──────────────────────────────────────────────────────────┐
│ resolve(A) │
│ │ │
│ ├── resolving = { A } │
│ │ │
│ └── factory(A) calls resolve(B) │
│ │ │
│ ├── resolving = { A, B } │
│ │ │
│ └── factory(B) calls resolve(A) │
│ │ │
│ ├── A is already in resolving │
│ │ │
│ └── Error: Circular dependency: A │
│ │
│ The cycle is detected and reported. │
│ │
└──────────────────────────────────────────────────────────┘
Visual: Token with Phantom Type
┌──────────────────────────────────────────────────────────┐
│ interface Token<T> { │
│ readonly key: symbol; │
│ readonly description: string; │
│ readonly __type?: T; ← phantom, never assigned │
│ } │
│ │
│ createToken<Database>('Database') │
│ │ │
│ ▼ │
│ Token<Database> { │
│ key: Symbol('Database'), │
│ description: 'Database', │
│ __type: undefined ← type-level only │
│ } │
│ │
│ container.resolve(DATABASE) │
│ │ │
│ ▼ │
│ T is inferred as Database │
│ Return type is Database │
│ │
└──────────────────────────────────────────────────────────┘
Visual: Testing with the Container
┌──────────────────────────────────────────────────────────┐
│ PRODUCTION │
│ │
│ container.register(DATABASE, () => new PostgresDatabase());│
│ container.register(LOGGER, () => new ConsoleLogger()); │
│ container.register(USER_SERVICE, (c) => │
│ new UserService(c.resolve(DATABASE), c.resolve(LOGGER)));│
│ │
│ container.resolve(USER_SERVICE) │
│ └── real PostgresDatabase │
│ │
├──────────────────────────────────────────────────────────┤
│ TEST │
│ │
│ testContainer.register(DATABASE, () => new InMemoryDatabase());│
│ testContainer.register(LOGGER, () => new SilentLogger());│
│ testContainer.register(USER_SERVICE, (c) => │
│ new UserService(c.resolve(DATABASE), c.resolve(LOGGER)));│
│ │
│ testContainer.resolve(USER_SERVICE) │
│ └── in-memory fake │
│ │
│ Same UserService class, different dependencies. │
│ │
└──────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Token | Unique identifier carrying a type |
| Phantom type | __type?: T — type-only field |
| Factory | (container: Container) => T |
| Registry | Map<symbol, Registration> |
| Resolve | Constructs and caches |
| Lifetimes | singleton, transient, scoped |
| Scope | Child container, fresh cache |
| Cycle detection | resolving set |
| Missing provider | Runtime error with description |
| Testing | Register test doubles |
Key takeaways:
- A type-safe DI container has three parts — a token that carries the type, a registry that maps tokens to factories, and a resolver that returns the constructed instance with the correct type
- The phantom type on the token is what makes the container type-safe —
__type?: Tis never assigned at runtime but lets the compiler infer the return type from the token - Symbols are the default token — they are unique, so no two tokens can collide, and the description makes them debuggable
- The factory receives the container — this is how a dependency’s own dependencies are resolved, recursively, from the registrations
- The default lifetime is singleton — one instance per container, cached on the first resolve, and this is right for most stateless services
- Transient constructs a new instance per resolve — for stateful objects that should not be shared
- Scoped constructs one instance per scope — for request-scoped services where each request gets its own instance but application services are shared
- Circular dependencies are detected by a resolving set — the container throws with the token’s description, and the fix is usually a refactor rather than a container feature
- The missing-provider error is a runtime error — the container cannot know at compile time what is registered, so the error message is what makes it diagnosable
- Testing is the payoff — the same class is used with production dependencies in the application and test doubles in the tests, and the container is what makes the swap clean
Remember: Dependency injection in TypeScript does not require a framework. A token with a phantom type, a registry, and a resolver are enough to build a container that is fully type-safe, handles lifetimes and scopes, and detects circular dependencies. The container is small — about a hundred lines — and it makes the wiring explicit, the errors compile-time or descriptive, and the tests trivial. For a library or a small application, this is the right size. For a large application, a framework like InversifyJS or NestJS adds decorators and automatic wiring on top of the same concepts.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!