What is TSyringe?
TSyringe is a lightweight TypeScript-first dependency injection container library that uses decorators and reflection to handle class dependencies automatically.
Install TSyringe
Install TSyringe and Reflect Metadata API, which it relies on:
npm install tsyringe reflect-metadata
Then enable the following in your tsconfig.json
:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
And import reflect-metadata
once, typically in your entry file:
import 'reflect-metadata';
Basic Usage
1. Define a service
This is a simple logger service. It will be injected into other classes. We use @injectable()
to make LoggerService
injectable.
@injectable()
export class LoggerService {
log(message: string) {
console.log(`[LOG]: ${message}`);
}
}
2. Register the service
Here, we tell TSyringe to associate a token with the LoggerService
class. This token can be a string (like 'LoggerService'
), a symbol, or even the class constructor itself.
import { container } from 'tsyringe';
import { LoggerService } from './logger.service';
container.register('LoggerService', { useClass: LoggerService });
In this example, we’re using a string token, which works fine — but TSyringe also supports using the class itself as the token:
container.register(LoggerService, { useClass: LoggerService });
Using the class as the token has the advantage of being type-safe and less error-prone than using strings, especially in large codebases.
3. Inject it into another class
We use @inject()
to request LoggerService
.
import { injectable, inject } from 'tsyringe';
@injectable()
class UserService {
constructor(@inject('LoggerService') private logger: LoggerService) {}
createUser(name: string) {
this.logger.log(`Creating user: ${name}`);
}
}
If you registered LoggerService
using the class as the token, you can inject it without @inject()
— TSyringe will infer the type using reflection metadata:
@injectable()
export class UserService {
constructor(private logger: LoggerService) {}
createUser(name: string) {
this.logger.log(`Creating user: ${name}`);
}
}
This is cleaner and fully type-safe, as long as all types are properly decorated and registered.
4. Resolve and use the class
Use the container to resolve UserService
. TSyringe will automatically:
- Inspect the constructor of
UserService
- See that it depends on
LoggerService
- Inject the registered implementation of
LoggerService
const userService = container.resolve(UserService);
userService.createUser('Alice');
Note that you didn’t have to register UserService
first, that’s because TSyringe can resolve classes decorated with @injectable()
automatically, as long as all their dependencies are registered.
Mocking and Overriding Dependencies for Unit Testing
One of the biggest advantages of using a dependency injection container like TSyringe is the ability to easily swap out real implementations with mocks or stubs during testing.
Overriding a dependency
To override a registered dependency in a test, you can call container.register()
again before resolving the class under test:
Using a class as the token:
import { container } from 'tsyringe';
import { UserService } from './user.service';
import { LoggerService } from './logger.service';
const mockLogger = {
log: jest.fn(),
};
// Override LoggerService with the mock
container.register(LoggerService, { useValue: mockLogger });
const userService = container.resolve(UserService);
userService.createUser('Test');
// Assert that the logger was called
expect(mockLogger.log).toHaveBeenCalledWith('Creating user: Test');
This approach is type-safe and avoids hardcoded strings. It pairs naturally with constructor injection that relies on type reflection (i.e., when no @inject()
is used).
Using a string token:
If you registered LoggerService
using a string token, you would also inject it with @inject('LoggerService')
and override it like this:
container.register('LoggerService', { useValue: mockLogger });
Using Interfaces with TSyringe
In large applications, relying on interfaces instead of concrete classes makes your code more flexible and testable. However, due to TypeScript’s limitations, TSyringe cannot resolve interfaces automatically — they don’t exist at runtime, so reflection metadata won’t capture them.
Why Use Interfaces?
Because it decouples abstractions from implementations; it makes your classes depend on contracts, not concrete classes. It’s also easier to test and mock, and it supports multiple implementations, allowing you to switch behaviors at runtime or in different environments.
How to Use Interfaces with TSyringe?
Since interfaces can’t be reflected at runtime, you must use tokens when injecting them. These can be strings, symbols, or InjectionToken objects.
1. Define the interface
export interface ILoggerService {
log(message: string): void;
}
2. Implement the interface
import { injectable } from 'tsyringe';
import { ILoggerService } from './logger.interface';
@injectable()
export class LoggerService implements ILoggerService {
log(message: string) {
console.log(`[LOG]: ${message}`);
}
}
3. Register the implementation using a token
Use a string as a token:
container.register<ILoggerService>('ILoggerService', {
useClass: LoggerService,
});
Or use Symbol()
to create unique, collision-resistant tokens that are safer and easier to maintain than plain strings:
export const TOKENS = {
ILoggerService: Symbol('ILoggerService'),
};
container.register<ILoggerService>(TOKENS.ILoggerService, {
useClass: LoggerService,
});
4. Inject the interface using the token
Use the @inject()
decorator with the same token:
import { injectable, inject } from 'tsyringe';
import { ILoggerService } from './logger.interface';
@injectable()
export class UserService {
constructor(@inject('ILoggerService') private logger: ILoggerService) {}
createUser(name: string) {
this.logger.log(`Creating user: ${name}`);
}
}
You must use @inject()
when injecting interfaces as TSyringe can’t infer them like it can with classes.