thinple
v0.1.0
Published
Type-safe dependency injection container for TypeScript - works in both Browser and Node.js
Maintainers
Readme
Thinple
A type-safe Dependency Injection (DI) container for TypeScript inspired by Pimple (PHP). Works seamlessly in both Browser and Node.js environments.
Why Thinple?
Stop runtime surprises, start with confidence. Traditional DI containers fail at runtime when services are missing or misconfigured. Thinple leverages TypeScript's type system to catch these errors at compile time, ensuring your application works before it even runs.
// ❌ Other containers: Runtime error
const missing = container.get('nonexistent'); // Throws at runtime
// ✅ Thinple: Compile-time error
const missing = container.get('nonexistent'); // TypeScript error - won't compileNo decorators, no magic. Unlike many DI frameworks that rely on decorators and reflection, Thinple uses a simple, explicit approach. No experimental features, no metadata, just pure TypeScript.
// ❌ Other frameworks: Decorator magic
@Injectable()
class UserService {
constructor(@Inject('DATABASE') private db: Database) {}
}
// ✅ Thinple: Simple and explicit
const container = new Container()
.set<UserService>((c) => new UserService(c.get('database')))('userService');Surprisingly simple API. Learn the entire API in minutes, not hours. Just a handful of methods that do exactly what you expect.
Installation
# npm
npm install thinple
# yarn
yarn add thinple
# pnpm
pnpm add thinpleRequirements: Node.js 18+ or modern browsers with ES2020+ support
Quick Start
Get up and running in 30 seconds:
import { Container } from 'thinple';
// 1. Create a container
const container = new Container();
// 2. Register a service
const appContainer = container
.set(() => 'Hello, World!')('greeting');
// 3. Use the service
const greeting = appContainer.get('greeting');
console.log(greeting); // "Hello, World!"Core Concepts
Services and Factories
Services are registered using factory functions that describe how to create them:
interface ApiClient {
baseUrl: string;
fetch(path: string): Promise<any>;
}
const container = new Container()
.set<ApiClient>(() => ({
baseUrl: 'https://api.example.com',
fetch: async (path) => { /* implementation */ }
}))('apiClient');Dependency Injection
Services can depend on other services by accessing the container:
interface Logger {
log(message: string): void;
}
interface UserService {
getUser(id: string): Promise<User>;
}
const container = new Container()
.set<Logger>(() => ({
log: (msg) => console.log(`[LOG] ${msg}`)
}))('logger')
.set<UserService>((c) => ({
getUser: async (id) => {
const logger = c.get('logger');
logger.log(`Fetching user ${id}`);
// ... implementation
}
}))('userService');Type Safety
Everything is type-checked. No more guessing what services are available:
const container = new Container()
.set(() => ({ message: 'Hello' }))('greeting');
// ✅ Valid - TypeScript knows 'greeting' exists
const greeting = container.get('greeting');
// ❌ Compile error - TypeScript knows 'missing' doesn't exist
const missing = container.get('missing');Service Types
Thinple supports different service lifecycles:
Regular Services (set)
Create a new instance every time:
const container = new Container()
.set(() => new Date())('timestamp'); // New date each timeShared Services (share) - Singleton
Create once, reuse everywhere. Perfect for expensive resources that should be shared across your application:
const container = new Container()
.share(() => new DatabaseConnection())('db'); // Same instance alwaysWhen to use share:
- Database connections - Connection pools are expensive to create
- External API clients - Maintain authentication, rate limiting, connection reuse
- Cache instances - Memory efficiency and data consistency
- Heavy initialization - Config parsing, cryptographic key generation
- Resource pools - Thread pools, worker pools
const container = new Container()
// ✅ Expensive resources - create once, share everywhere
.share(() => new PostgresDatabase({
connectionPool: { max: 20, min: 5 }
}))('database')
.share(() => new RedisClient({
host: 'redis.example.com',
retryDelayOnFailover: 1000
}))('cache')
.share(() => new ExternalApiClient({
apiKey: process.env.API_KEY,
rateLimit: 1000 // requests per minute
}))('apiClient')
// ✅ Regular services - new instances are fine
.set((c) => new UserRepository(c.get('database')))('userRepository')
.set((c) => new UserService(c.get('userRepository'), c.get('cache')))('userService');
// All UserService instances share the same database connection
const userService1 = container.get('userService');
const userService2 = container.get('userService');
// userService1 and userService2 use the same database connection instanceRaw Values (raw)
Store pre-computed values:
const container = new Container()
.raw({ apiUrl: 'https://api.example.com' })('config');Protected Services (protect)
Prevent accidental overrides:
const container = new Container()
.protect(() => new CriticalService())('critical'); // Cannot be overriddenAdvanced Features
Service Reservation
Reserve service names before implementation for better dependency management:
interface Logger {
log(message: string): void;
}
// Reserve 'logger' service without implementing it yet
const container = new Container()
.reserve<Logger, 'logger'>()
.set((c) => ({
doSomething: () => {
c.get('logger').log('Doing something'); // TypeScript knows logger exists
}
}))('app');
// Provide implementation later
const app = container.get('app', {
logger: () => ({ log: console.log })
});Container Merging
Combine multiple containers:
const databaseContainer = new Container()
.share(() => new Database())('db');
const loggingContainer = new Container()
.set(() => new FileLogger())('logger');
const appContainer = new Container()
.merge(databaseContainer)
.merge(loggingContainer)
.set((c) => new AppService(c.get('db'), c.get('logger')))('app');Service Extension
Wrap existing services to add functionality:
const container = new Container()
.set(() => new BasicLogger())('logger')
.extend('logger', (originalLogger) =>
new TimestampLogger(originalLogger)
);Real-World Example
Here's how you might structure a typical web application:
// types.ts
interface Database {
query<T>(sql: string, params?: any[]): Promise<T[]>;
}
interface Logger {
info(message: string): void;
error(message: string, error?: Error): void;
}
interface UserRepository {
findById(id: string): Promise<User | null>;
create(userData: CreateUserData): Promise<User>;
}
interface UserService {
getUser(id: string): Promise<User>;
createUser(data: CreateUserData): Promise<User>;
}
// container.ts
export const createContainer = () => new Container()
// Infrastructure
.share<Database>(() => new PostgresDatabase({
host: process.env.DB_HOST,
database: process.env.DB_NAME,
}))('database')
.share<Logger>(() => new WinstonLogger({
level: process.env.LOG_LEVEL || 'info',
}))('logger')
// Repositories
.set<UserRepository>((c) => new UserRepositoryImpl(
c.get('database'),
c.get('logger')
))('userRepository')
// Services
.set<UserService>((c) => new UserServiceImpl(
c.get('userRepository'),
c.get('logger')
))('userService');
// app.ts
const container = createContainer();
const userService = container.get('userService');
// Everything is type-safe and ready to use
const user = await userService.getUser('123');API Reference
Container Methods
| Method | Description | Returns |
|--------|-------------|---------|
| set<T>(factory) | Register a service factory | (key: string) => Container |
| share<T>(factory) | Register a shared service (singleton) | (key: string) => Container |
| raw<T>(value) | Register a raw value | (key: string) => Container |
| protect<T>(factory) | Register a protected service | (key: string) => Container |
| get<K>(key, implementations?) | Retrieve a service | Service<K> |
| has(key) | Check if service exists | boolean |
| extend<K>(key, extender) | Extend existing service | Container |
| merge<T>(container) | Merge another container | Container |
| reserve<T, K>() | Reserve a service slot | Container |
Type Definitions
type ServiceFactory<T, TServices> = (container: Container<TServices>) => T;
type ServiceExtender<T, TServices> = (service: T, container: Container<TServices>) => T;Testing with Thinple
Perfect Test Isolation
Thinple's killer feature for testing: Every .set() call creates a new Container instance, leaving the original unchanged. This means you can safely derive test containers from your production setup without any interference between tests.
// production.ts
export const container = new Container()
.share(() => new PostgresDatabase())('database')
.share(() => new RedisCache())('cache')
.set((c) => new UserRepository(c.get('database')))('userRepository')
.set((c) => new UserService(c.get('userRepository'), c.get('cache')))('userService');
// test.ts
import { container as prodContainer } from './production';
describe('UserService', () => {
it('should handle repository errors', () => {
// ✅ Production setup + MockRepository only
// Original prodContainer is completely unchanged
const testContainer = prodContainer
.set(() => new MockUserRepositoryWithError())('userRepository');
const userService = testContainer.get('userService');
expect(() => userService.getUser('invalid')).toThrow('User not found');
});
it('should work with different mocks', () => {
// ✅ Different test, different container - zero interference
const testContainer = prodContainer
.set(() => new MockUserRepositoryWithDelay())('userRepository');
// This test is completely independent from the previous one
const userService = testContainer.get('userService');
// ... test logic
});
});Why This Matters
No test interference: Each test gets its own container instance. Run tests in parallel, in any order, with complete confidence.
// All of these run independently with zero conflicts
const testA = prodContainer.set(() => new MockA())('serviceA');
const testB = prodContainer.set(() => new MockB())('serviceA');
const testC = prodContainer.set(() => new MockC())('serviceA');
// prodContainer remains unchanged, each test has isolated behaviorTesting Strategies
Strategy 1: Production-based (Recommended)
import { container } from '../src/container';
// Replace only what you need to mock
const testContainer = container
.set(() => new MockDatabase())('database')
.set(() => new TestLogger())('logger');Strategy 2: Full Mocks
// Build test container from scratch
const testContainer = new Container()
.set(() => new MockDatabase())('database')
.set(() => new MockLogger())('logger')
.set((c) => new UserService(c.get('database'), c.get('logger')))('userService');Strategy 3: Shared Test Setup
// For integration tests where you want shared expensive resources
describe('UserService Integration', () => {
let baseContainer: Container;
beforeAll(() => {
baseContainer = new Container()
.share(() => new TestDatabase())('database'); // Expensive setup once
});
it('should persist user', () => {
const testContainer = baseContainer
.set((c) => new UserRepository(c.get('database')))('userRepository');
// Test with real database, fresh repository per test
});
});For Contributors
🚀 Thinple is in active development! We're building something awesome and would love your help. Whether you're fixing bugs, adding features, improving documentation, or just sharing ideas - every contribution matters. Don't hesitate to jump in, we're a friendly community!
Development Setup
Clone and install dependencies:
git clone https://github.com/yourusername/thinple.git cd thinple npm installAvailable scripts:
npm run dev # Watch mode development npm test # Run tests in watch mode npm run test:run # Run tests once npm run build # Build for production npm run typecheck # Type checking npm run lint # Lint code npm run format # Format code
Project Structure
src/
├── container.ts # Main Container implementation
├── types.ts # Type definitions
├── index.ts # Public API exports
└── __tests__/ # Test suites
├── container.test.ts
├── container.merge.test.ts
└── container.reserve.test.tsRunning Tests
# Watch mode (recommended for development)
npm test
# Run once
npm run test:run
# With coverage
npm run test:coverageBuilding
# Build for all targets (ESM, CJS, types)
npm run build
# Check types only
npm run typecheckCode Standards
- TypeScript: Strict mode enabled
- Formatting: Biome (auto-format on save recommended)
- Testing: Vitest with comprehensive coverage
- Commits: Conventional commit format preferred
Contributing
- Fork the repository
- Create a feature branch:
git checkout -b feature-name - Make changes with tests
- Ensure all tests pass:
npm run test:run - Submit a pull request
For bugs and feature requests, please create an issue.
