This article is published in English.
NestJS Injection Tokens: Why useClass Can Silently Duplicate Singletons
Understand types, tokens and providers in NestJS DI, why interfaces cannot be injected, and how useExisting avoids two instances of the same stateful service.
NestJS dependency injection seems effortless at first: declare a constructor parameter, and the framework hands you a ready instance. That ease hides a mapping between TypeScript types, runtime tokens and provider definitions that, if misunderstood, produces bugs that are hard to see. Think duplicated caches, extra database pools or mocks that never resolve. This article builds a precise mental model of how NestJS resolves dependencies, explains why interfaces cannot serve as injection keys, and shows how a single provider option decides whether a service ends up with one instance or two.
From automatic wiring to explicit tokens
The typical first encounter with NestJS DI is a constructor like this:
constructor(
private readonly emailService: EmailService,
) {}
For a class dependency, NestJS reads the metadata TypeScript emits about constructor parameter types (enabled by emitDecoratorMetadata) and uses the class itself to look up the matching provider. No extra annotation is needed.
Larger codebases often look different:
constructor(
@Inject('EMAIL_SERVICE')
private readonly emailService: EmailService,
) {}
If the parameter is already typed, what does @Inject() add, and why can switching useClass to useExisting change whether you get one instance or two? Answering that requires separating three ideas that usually share a name.
Type, token and provider are three different things
Beginners often treat these as one concept because in the simple case they share an identifier:
- Type: what the TypeScript compiler uses to check your code. It exists only at compile time.
- Token: the runtime key NestJS uses to find a provider in its container.
- Provider: the registration that tells NestJS how to produce a value for a token, whether by instantiating a class, reusing another provider, returning a fixed value or calling a factory.
With plain class-based injection, one class does double duty as both the TypeScript type and the NestJS token:
CustomLoggerService
↓
NestJS Token
+
TypeScript Type
A custom token splits those roles apart. In the following constructor, the string and the type annotation serve completely different purposes:
constructor(
@Inject('logger')
private readonly logger: CustomLoggerService,
)
Broken down, the responsibilities look like this:
@Inject('logger')
↓
NestJS Token (Finds the provider in memory)
: CustomLoggerService
↓
TypeScript Type (Gives you IDE autocomplete)
The token is what NestJS uses to find the instance at runtime. The type annotation only gives the compiler and your editor knowledge of its shape, for type checking and autocomplete. A useful shorthand: the token locates the box, the type describes its contents. Note that NestJS does not verify the two agree; if the provider behind 'logger' returns something else, TypeScript will not catch it.
Why you cannot inject an interface
A frequent request from developers new to NestJS is to depend on an interface to keep code decoupled, for example by typing a parameter as IMailService. The natural first attempt fails:
// Won't work at runtime
constructor(
private readonly mailService: IMailService,
) {}
At startup, NestJS reports Nest can't resolve dependencies of the UserService (?). The question mark marks the parameter it could not identify.
The reason is that interfaces do not exist in JavaScript. When tsc compiles your code, every interface and every pure type annotation is erased. NestJS depends on metadata that survives into the running program, and an interface leaves nothing behind for it to read; the emitted metadata for that parameter degrades to the generic Object, which matches no provider.
Classes are different: they compile to real JavaScript constructor functions, so they still exist at runtime and can act as keys.
class CustomLoggerService
↓
exists at runtime
↓
can be used as a DI token
interface IMailer
↓
erased during compilation
↓
cannot be used as a runtime DI token
So when you program against an abstraction, you have to supply a runtime token explicitly, such as @Inject('MAIL_SERVICE'), and register a provider under that same token. An abstract class is an alternative worth knowing: because it compiles to a real function, it can act as both the type and the token without @Inject().
The duplicate singleton trap: useClass versus useExisting
Once custom tokens are in play, each module has to tell NestJS how to resolve them, and this is where a subtle bug commonly slips in. Consider this module:
@Module({
providers: [
CustomLoggerService,
{
provide: 'APP_LOGGER',
useClass: CustomLoggerService, // The trap
},
],
})
export class CommonModule {}
At a glance it looks like 'APP_LOGGER' is just another name for CustomLoggerService. It is not. The module now contains two independent provider registrations, and with the default singleton scope each one gets its own instance:
- The class token
CustomLoggerServiceis resolved by constructing the class, producing instance A. - The string token
'APP_LOGGER'is resolved by constructing the class again, producing instance B.
The problem appears only with state. If the logger keeps an in-memory buffer, holds a queue, counts requests for rate limiting or owns a connection, consumers injecting the class and consumers injecting the string token are talking to different objects that never see each other's data.
The fix: an alias with useExisting
When the intent is an additional name for a provider that is already registered, use useExisting. It tells NestJS not to create anything new and to resolve the token to the existing instance instead:
@Module({
providers: [
CustomLoggerService,
{
provide: 'APP_LOGGER',
useExisting: CustomLoggerService, // Points to the existing singleton
},
],
})
export class CommonModule {}
A simple way to picture the difference is boxes and nametags:
useClassbuilds a second box. The class token labels box A, the string token labels box B.useExistingbuilds one box and attaches both nametags to it.
With the alias in place, a consumer that injects CustomLoggerService and another that injects 'APP_LOGGER' receive the same object, so a strict equality check between them is true.
useClass is still the right choice when you genuinely want a separate instance, or when the token is the only way the class is registered, as in the next section.
Tokens as an architectural seam
For a small CRUD service, custom tokens can look like ceremony. Their payoff shows up when an implementation is likely to change. Suppose dozens of controllers log through a Winston-based service. Rather than importing WinstonLoggerService in each of them, the controllers depend only on a token and an interface:
constructor(
@Inject('LOGGER') private readonly logger: LoggerInterface
) {}
The module decides which implementation sits behind that token:
{
provide: 'LOGGER',
useClass: WinstonLoggerService,
}
Moving to a cloud logging backend, such as Google Cloud Logging or AWS CloudWatch, then becomes a one-line change in the module, with no edits to any consumer:
{
provide: 'LOGGER',
useClass: CloudLoggerService,
}
The same seam makes testing straightforward, because a test module can bind 'LOGGER' to a stub without touching the code under test.
A practical refinement: plain strings are easy to mistype and can collide across modules. Defining tokens once as exported constants, or as Symbol values, keeps them consistent and lets the compiler catch typos.
Provider patterns at a glance
The four provider shapes you will meet most often, and when each is appropriate:
- Class provider (
useClass): instantiate a class for a token. Use it to bind an abstraction token to a concrete implementation, or to swap implementations per environment. Each registration creates its own instance. - Alias provider (
useExisting): point a token at a provider that already exists. Use it to expose one instance under several names without duplicating state. - Value provider (
useValue): return a fixed value, such as a configuration object, a constant or a mock in tests. - Factory provider (
useFactory): compute the value with a function, optionally with injected dependencies listed ininject. Use it when creation depends on configuration or is asynchronous, as with database connections.
Do not forget exports
A classic stumbling block is a token configured correctly in CommonModule that UserModule still cannot resolve. The providers array controls what a module registers for its own use. The exports array controls what it makes available to modules that import it. A custom token such as 'LOGGER' must appear in exports (and the consuming module must import CommonModule) before anyone outside can inject it. When you export an alias created with useExisting, check that consumers can also reach the provider it points to, or export both tokens.
Key takeaways
- A type is compile-time only, a token is the runtime key, and a provider defines how that key is satisfied; keep the three apart when reading DI code.
- Interfaces are erased during compilation, so they need an explicit token via
@Inject(), or an abstract class that exists at runtime. - Registering the same class under two tokens with
useClasscreates two singletons; useuseExistingwhen you only want an alias. - Abstraction tokens let you swap implementations and mocks from one place; define them as shared constants or symbols.
- When a token cannot be resolved in another module, check
exportsandimportsfirst. - For every injection, ask three questions: what is the token, what implementation backs it, and is it a separate registration or an alias?