Skip to main content

Interfaces

TypeScript interfaces are erased at compile time — they do not exist at runtime, so they cannot be used directly as injection keys. The solution is to create a symbol or string token that acts as the runtime key for the interface.


Defining a token

Declare a symbol (or string) alongside the interface and use it everywhere you would otherwise use the interface type as a key.

export interface Repository {
save(entity: unknown): Promise<void>
findById(id: string): Promise<unknown>
}

export const kRepository = Symbol('Repository')

Register an implementation under that token:

import { Injectable } from '@caffeine-projects/dicaf/decorators'

@Injectable(kRepository)
class InMemoryRepository implements Repository {
async save(entity: unknown) { /* ... */ }
async findById(id: string) { /* ... */ }
}

Inject it using the token:

@Injectable([kRepository])
class UserService {
constructor(private readonly repo: Repository) {}
}

Multiple implementations with allOf

Register multiple implementations under the same symbol token, then collect all of them with allOf.

import { Injectable, Named } from '@caffeine-projects/dicaf/decorators'
import { allOf } from '@caffeine-projects/dicaf'

interface Processor {
process(input: string): string
}

const kProcessor = Symbol('Processor')

@Named(kProcessor)
@Injectable()
class UpperCaseProcessor implements Processor {
process(input: string) { return input.toUpperCase() }
}

@Named(kProcessor)
@Injectable()
class TrimProcessor implements Processor {
process(input: string) { return input.trim() }
}

@Named(kProcessor)
@Injectable()
class SanitizeProcessor implements Processor {
process(input: string) { return input.replace(/<[^>]*>/g, '') }
}

@Injectable([allOf(kProcessor)])
class Pipeline {
constructor(private readonly processors: Processor[]) {}

run(input: string): string {
return this.processors.reduce((acc, p) => p.process(acc), input)
}
}

@Named(kProcessor) registers each class under the shared symbol. allOf(kProcessor) collects all of them into an array at injection time.

warning

Injecting the token directly — without allOf — when multiple implementations are registered throws ErrNoUniqueInjectionForKey. Mark one implementation @Primary, use @ConditionalOn so only one survives at runtime, or inject by a more specific name or token.


Selecting a single implementation with @Primary

import { Injectable, Named, Primary } from '@caffeine-projects/dicaf/decorators'

interface UserRepository {
findById(id: string): Promise<User | undefined>
save(user: User): Promise<void>
}

const kUserRepository = Symbol('UserRepository')

@Named(kUserRepository)
@Injectable()
class InMemoryUserRepository implements UserRepository {
private store = new Map<string, User>()
async findById(id: string) { return this.store.get(id) }
async save(user: User) { this.store.set(user.id, user) }
}

@Primary()
@Named(kUserRepository)
@Injectable()
class PostgresUserRepository implements UserRepository {
async findById(id: string) { /* query postgres */ }
async save(user: User) { /* insert into postgres */ }
}

// Resolves to PostgresUserRepository because it is @Primary
@Injectable([kUserRepository])
class UserService {
constructor(private readonly repo: UserRepository) {}
}

Naming implementations with @Named

Assign each implementation a distinct name for targeted injection or runtime dispatch.

import { Injectable, Named } from '@caffeine-projects/dicaf/decorators'
import { mapped } from '@caffeine-projects/dicaf'

interface NotificationSender {
send(message: string, to: string): Promise<void>
}

const kNotificationSender = Symbol('NotificationSender')

@Named(kNotificationSender, 'email')
@Injectable()
class EmailSender implements NotificationSender {
async send(message: string, to: string) { /* send email */ }
}

@Named(kNotificationSender, 'sms')
@Injectable()
class SmsSender implements NotificationSender {
async send(message: string, to: string) { /* send SMS */ }
}

// Inject a specific implementation by string name
@Injectable(['email'])
class OrderService {
constructor(private readonly sender: NotificationSender) {}
}

Inject all implementations as a Map<string, T> using mapped() for runtime dispatch:

@Injectable([mapped(kNotificationSender)])
class NotificationRouter {
constructor(private readonly senders: Map<string, NotificationSender>) {}

async route(channel: string, message: string, to: string) {
const sender = this.senders.get(channel)
if (!sender) throw new Error(`No sender for channel: ${channel}`)
await sender.send(message, to)
}
}

Conditional implementations with @ConditionalOn

import { Injectable, Named, Primary, ConditionalOn } from '@caffeine-projects/dicaf/decorators'

interface CacheStore {
get(key: string): Promise<string | undefined>
set(key: string, value: string): Promise<void>
}

const kCacheStore = Symbol('CacheStore')

// Always registered — acts as the default
@Named(kCacheStore)
@Injectable()
class InMemoryCache implements CacheStore {
private store = new Map<string, string>()
async get(key: string) { return this.store.get(key) }
async set(key: string, value: string) { this.store.set(key, value) }
}

// Only registered when a RedisClient binding is present in the container
@Primary()
@ConditionalOn(ctx => ctx.container.has(RedisClient))
@Named(kCacheStore)
@Injectable()
class RedisCache implements CacheStore {
constructor(private readonly client: RedisClient) {}
async get(key: string) { return this.client.get(key) }
async set(key: string, value: string) { await this.client.set(key, value) }
}

All bindings are registered before any @ConditionalOn predicate runs, so ctx.container.has() is safe to call for any key.


Manual bindings (without decorators)

import { DiCaf } from '@caffeine-projects/dicaf'

interface Cache {
get(key: string): string | undefined
set(key: string, value: string): void
}

const kCache = Symbol('Cache')

class MemCache implements Cache {
private store = new Map<string, string>()
get(key: string) { return this.store.get(key) }
set(key: string, value: string) { this.store.set(key, value) }
}

class RedisCache implements Cache {
get(key: string) { /* ... */ return undefined }
set(key: string, value: string) { /* ... */ }
}

const di = new DiCaf()

di.bind(kCache).toClass(RedisCache)

await di.init()

const cache = di.get<Cache>(kCache) // RedisCache

.names(token) registers the binding under the symbol token in addition to its own class key. .primary() makes it the default when resolving a single instance by that token.