TypeScript Dependency Injection: A PHP Developer's Perspective

As a PHP developer, you're likely accustomed to mature DI containers like Symfony's Service Container or PHP-DI. TypeScript's approach to dependency injection is fundamentally different—not just in implementation, but in philosophy. Let's explore why.

The Fundamental Difference: Type Systems

Before diving into DI specifics, we need to understand the core difference between PHP and TypeScript's type systems:

PHP: Nominal Typing

PHP uses nominal typing—types are based on explicit declarations. A class must explicitly implement an interface or extend a class to be considered compatible:

<?php
interface LoggerInterface {
    public function log(string $message): void;
}

class FileLogger implements LoggerInterface {
    public function log(string $message): void {
        // Implementation
    }
}

class Service {
    // Must explicitly implement LoggerInterface
    public function __construct(private LoggerInterface $logger) {}
}

// This won't work - EmailLogger doesn't implement LoggerInterface
class EmailLogger {
    public function log(string $message): void {
        // Same method signature, but not explicitly implementing the interface
    }
}

// Type error: EmailLogger is not a LoggerInterface
// $service = new Service(new EmailLogger());

TypeScript: Structural Typing

TypeScript uses structural typing (also called "duck typing")—if it walks like a duck and quacks like a duck, it's a duck:

interface Logger {
    log(message: string): void;
}

class FileLogger implements Logger {
    log(message: string): void {
        // Implementation
    }
}

class Service {
    constructor(private logger: Logger) {}
}

// This DOES work - structural typing!
class EmailLogger {
    log(message: string): void {
        // Same method signature, no explicit interface needed
    }
}

// Perfectly valid - EmailLogger has the right "shape"
const service = new Service(new EmailLogger());

// Even this works!
const customLogger = {
    log: (message: string) => console.log(message)
};
const service2 = new Service(customLogger);

This fundamental difference cascades through everything, including how dependency injection works.

No Final Classes = Everything is Mockable

In PHP, you might use final to prevent inheritance:

<?php
final class PaymentProcessor {
    private array $transactions = [];
    
    public function process(float $amount): void {
        // Critical business logic that shouldn't be modified
        $this->transactions[] = $amount;
    }
}

// This causes an error - can't extend final class
// class MockPaymentProcessor extends PaymentProcessor {}

TypeScript has no concept of final classes. This design choice, combined with structural typing, means everything can be mocked or stubbed for testing:

// In TypeScript, there's no "final" keyword
class PaymentProcessor {
    private transactions: number[] = [];
    
    process(amount: number): void {
        this.transactions.push(amount);
    }
}

// Testing? Just mock it!
class MockPaymentProcessor extends PaymentProcessor {
    process(amount: number): void {
        console.log(`Mock: Processing ${amount}`);
    }
}

// Or use partial mocking with structural typing
const mockProcessor: PaymentProcessor = {
    process: jest.fn(),
    // TypeScript only cares about public interface
} as any;

// Or create a test double that matches the shape
const testProcessor = {
    process: (amount: number) => {
        // Test implementation
    }
};

This is both liberating and dangerous. While it makes testing easier, it also means you can't enforce certain architectural boundaries through the type system alone.

The Fragmented Landscape: No Standard DI

PHP has converged around PSR-11 Container Interface, with most frameworks implementing compatible containers. TypeScript? It's the Wild West.

Popular TypeScript DI Libraries (as of July 2025)

InversifyJS

import "reflect-metadata";
import { Container, injectable, inject } from "inversify";

// Define tokens for interfaces
const TYPES = {
    Logger: Symbol.for("Logger"),
    UserRepository: Symbol.for("UserRepository"),
    UserService: Symbol.for("UserService")
};

// Interfaces
interface Logger {
    log(message: string): void;
}

interface UserRepository {
    findById(id: string): User | null;
}

// Implementations must be decorated
@injectable()
class ConsoleLogger implements Logger {
    log(message: string): void {
        console.log(message);
    }
}

@injectable()
class PostgresUserRepository implements UserRepository {
    findById(id: string): User | null {
        // Database logic
        return null;
    }
}

@injectable()
class UserService {
    constructor(
        @inject(TYPES.Logger) private logger: Logger,
        @inject(TYPES.UserRepository) private userRepo: UserRepository
    ) {}
    
    getUser(id: string): User | null {
        this.logger.log(`Fetching user ${id}`);
        return this.userRepo.findById(id);
    }
}

// Container configuration
const container = new Container();
container.bind<Logger>(TYPES.Logger).to(ConsoleLogger);
container.bind<UserRepository>(TYPES.UserRepository).to(PostgresUserRepository);
container.bind<UserService>(TYPES.UserService).to(UserService);

// Resolution
const userService = container.get<UserService>(TYPES.UserService);

TSyringe (Microsoft)

  • Lightweight, minimalist approach
  • Also decorator-based with reflect-metadata
  • Supports circular dependencies
  • Less configuration than InversifyJS
import "reflect-metadata";
import { injectable, container, inject } from "tsyringe";

// Interfaces
interface Logger {
    log(message: string): void;
}

interface UserRepository {
    findById(id: string): User | null;
}

// Simple decoration - no need for explicit tokens
@injectable()
class ConsoleLogger implements Logger {
    log(message: string): void {
        console.log(message);
    }
}

@injectable()
class PostgresUserRepository implements UserRepository {
    findById(id: string): User | null {
        return null;
    }
}

// For interfaces, use injection tokens
@injectable()
class UserService {
    constructor(
        @inject("Logger") private logger: Logger,
        @inject("UserRepository") private userRepo: UserRepository
    ) {}
    
    getUser(id: string): User | null {
        this.logger.log(`Fetching user ${id}`);
        return this.userRepo.findById(id);
    }
}

// Registration
container.register("Logger", { useClass: ConsoleLogger });
container.register("UserRepository", { useClass: PostgresUserRepository });

// Resolution - simpler than InversifyJS
const userService = container.resolve(UserService);

Manual DI / Pure Functions

Many TypeScript developers skip DI containers entirely, preferring manual dependency injection or functional approaches:

// Factory functions
function createLogger(): Logger {
    return {
        log: (message: string) => console.log(message)
    };
}

function createUserRepository(db: Database): UserRepository {
    return {
        findById: (id: string) => db.query(`SELECT * FROM users WHERE id = ?`, [id])
    };
}

function createUserService(logger: Logger, userRepo: UserRepository): UserService {
    return {
        getUser: (id: string) => {
            logger.log(`Fetching user ${id}`);
            return userRepo.findById(id);
        }
    };
}

// Composition root
function createApp(config: AppConfig) {
    const db = new Database(config.dbUrl);
    const logger = createLogger();
    const userRepo = createUserRepository(db);
    const userService = createUserService(logger, userRepo);
    
    return {
        userService,
        // ... other services
    };
}

// Usage
const app = createApp({ dbUrl: process.env.DATABASE_URL });
const user = app.userService.getUser("123");

The Interface Problem

In PHP, interfaces exist at runtime. You can type-hint against them:

<?php
interface CacheInterface {
    public function get(string $key): mixed;
    public function set(string $key, mixed $value): void;
}

class RedisCache implements CacheInterface {
    public function get(string $key): mixed {
        // Redis implementation
    }
    
    public function set(string $key, mixed $value): void {
        // Redis implementation
    }
}

// In your DI container configuration
$container->bind(CacheInterface::class, RedisCache::class);

// Type-hint against the interface
class ProductService {
    public function __construct(
        private CacheInterface $cache  // Interface exists at runtime
    ) {}
}

TypeScript interfaces don't exist at runtime—they're compile-time only. This creates challenges for DI containers:

// This interface disappears after compilation
interface Cache {
    get(key: string): any;
    set(key: string, value: any): void;
}

class RedisCache implements Cache {
    get(key: string): any {
        // Redis implementation
    }
    
    set(key: string, value: any): void {
        // Redis implementation
    }
}

// Problem: Can't do this - interfaces don't exist at runtime
// container.bind(Cache, RedisCache); // ERROR!

// Solution 1: Use injection tokens
const CACHE_TOKEN = Symbol('Cache');
container.bind(CACHE_TOKEN, RedisCache);

// Solution 2: Use abstract classes (they DO exist at runtime)
abstract class CacheBase {
    abstract get(key: string): any;
    abstract set(key: string, value: any): void;
}

class RedisCacheImpl extends CacheBase {
    get(key: string): any { /* ... */ }
    set(key: string, value: any): void { /* ... */ }
}

// This works because CacheBase exists at runtime
container.bind(CacheBase, RedisCacheImpl);

This is why TypeScript DI libraries rely heavily on:

Configuration Complexity

Setting up DI in TypeScript requires more boilerplate than PHP. Here's what you need:

tsconfig.json Requirements

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "experimentalDecorators": true,        // Required for decorators
    "emitDecoratorMetadata": true,         // Required for reflect-metadata
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "types": ["reflect-metadata"],         // Include reflect-metadata types
    "moduleResolution": "node"
  }
}

Polyfill Setup

First, install the required packages using npm or yarn:

npm install reflect-metadata inversify
# or
yarn add reflect-metadata inversify

Then configure your entry point:

// In your entry point (index.ts or main.ts)
import "reflect-metadata"; // Must be imported before any decorators

// For TSyringe specifically
import { container } from "tsyringe";

// For InversifyJS
import { Container } from "inversify";

// Then your application code
import { UserService } from "./services/UserService";

// The polyfill adds Reflect API globally
// Without it, decorators won't work at runtime

Compare this to PHP where DI typically "just works" with minimal configuration.

Testing: The Good and The Bad

The Good: Ultimate Flexibility

TypeScript's structural typing makes creating test doubles trivial. With testing frameworks like Jest, Mocha, or Vitest, mocking becomes incredibly simple:

// No need for complex mocking libraries
interface EmailService {
    send(to: string, subject: string, body: string): Promise<void>;
}

// In your test
describe('UserRegistration', () => {
    it('should send welcome email', async () => {
        // Create a test double with just the shape we need
        const sentEmails: any[] = [];
        const mockEmailService: EmailService = {
            send: async (to, subject, body) => {
                sentEmails.push({ to, subject, body });
            }
        };
        
        const registration = new UserRegistration(mockEmailService);
        await registration.register("user@example.com");
        
        expect(sentEmails).toHaveLength(1);
        expect(sentEmails[0].subject).toBe("Welcome!");
    });
});

// Or use partial mocking for complex objects
const mockUserRepo: Partial<UserRepository> = {
    save: jest.fn().mockResolvedValue({ id: "123" }),
    // Don't need to implement findById, delete, etc.
};

const service = new UserService(mockUserRepo as UserRepository);

The Bad: No Compile-Time Safety

Without final classes or sealed types, you can't prevent certain anti-patterns:

// In PHP, you might prevent this with 'final'
class CriticalPaymentService {
    private secretKey: string = "sk_live_xxxx";
    
    processPayment(amount: number): void {
        // Critical logic
    }
}

// But in TypeScript, nothing stops this:
class HackedPaymentService extends CriticalPaymentService {
    processPayment(amount: number): void {
        console.log("Stealing payment info!");
        super.processPayment(amount);
    }
}

// Or even worse - monkey patching at runtime:
const service = new CriticalPaymentService();
const originalMethod = service.processPayment;
service.processPayment = function(amount: number) {
    console.log("Intercepted payment:", amount);
    originalMethod.call(this, amount);
};

// No compile-time protection against these modifications!

Architectural Implications

1. Boundaries are Conventions, Not Constraints

In PHP, you can enforce architectural boundaries through visibility modifiers and final classes. In TypeScript, these boundaries are more suggestions than rules.

2. Runtime Type Checking

Since TypeScript types disappear at runtime, you might need libraries like Zod or Superstruct for runtime validation—something PHP handles natively.

3. Framework Lock-in

Each TypeScript framework tends to have its own DI approach:

Practical Recommendations

For PHP Developers Moving to TypeScript

1. Start Simple

Don't immediately reach for a DI container. TypeScript's module system and manual DI might be sufficient:

// services/logger.ts
export class Logger {
    log(message: string): void {
        console.log(`[${new Date().toISOString()}] ${message}`);
    }
}

// services/userRepository.ts
import { Database } from './database';

export class UserRepository {
    constructor(private db: Database) {}
    
    async findById(id: string): Promise<User | null> {
        return this.db.query('SELECT * FROM users WHERE id = ?', [id]);
    }
}

// services/userService.ts
import { Logger } from './logger';
import { UserRepository } from './userRepository';

export class UserService {
    constructor(
        private logger: Logger,
        private userRepository: UserRepository
    ) {}
    
    async getUser(id: string): Promise<User | null> {
        this.logger.log(`Fetching user ${id}`);
        return this.userRepository.findById(id);
    }
}

// app.ts - Manual wiring
import { Database } from './services/database';
import { Logger } from './services/logger';
import { UserRepository } from './services/userRepository';
import { UserService } from './services/userService';

const db = new Database(process.env.DATABASE_URL!);
const logger = new Logger();
const userRepo = new UserRepository(db);
const userService = new UserService(logger, userRepo);

export { userService };

2. Embrace Structural Typing

Stop thinking in terms of "implements" and start thinking in terms of "shape":

// Don't think "UserService needs ILogger interface"
// Think "UserService needs something with a log method"

type Loggable = {
    log(message: string): void;
};

class UserService {
    constructor(private logger: Loggable) {}
    
    doSomething(): void {
        this.logger.log("Doing something");
    }
}

// All of these work!
const service1 = new UserService(console);
const service2 = new UserService({ log: msg => fs.appendFileSync('app.log', msg) });
const service3 = new UserService({ log: msg => sendToElasticsearch(msg) });

// Even this minimal object works
const minimalLogger = { log: () => {} };
const service4 = new UserService(minimalLogger);

// This is the power of structural typing - 
// focus on capabilities, not classifications

3. Use Injection Tokens Wisely

When you do use a DI container, prefer symbols over strings:

// ❌ Avoid string tokens - typos and collisions
container.register("UserService", { useClass: UserService });
container.register("userService", { useClass: MockUserService }); // Oops!

// ✅ Prefer symbols - unique and type-safe
export const TOKENS = {
    UserService: Symbol.for('UserService'),
    Logger: Symbol.for('Logger'),
    Database: Symbol.for('Database'),
    Cache: Symbol.for('Cache'),
} as const;

container.register(TOKENS.UserService, { useClass: UserService });

// Even better - create a typed registry
interface ServiceRegistry {
    [TOKENS.UserService]: UserService;
    [TOKENS.Logger]: Logger;
    [TOKENS.Database]: Database;
    [TOKENS.Cache]: Cache;
}

// Now you get type safety when resolving
const userService = container.resolve<ServiceRegistry[typeof TOKENS.UserService]>(
    TOKENS.UserService
); // Type is UserService, not any!

4. Don't Over-Engineer

The JavaScript ecosystem values simplicity. A 500-line DI configuration might be normal in Symfony but is a code smell in TypeScript.

The Philosophical Divide

The differences in DI approaches reflect deeper philosophical differences:

PHP/Symfony Approach TypeScript/Node.js Approach
Configuration over code Code over configuration
Explicit contracts Implicit compatibility
Framework-provided solutions Community-driven variety
Runtime type safety Compile-time type checking
Standardization (PSR) Innovation through competition

Conclusion

Coming from PHP, TypeScript's approach to dependency injection can feel chaotic and underdeveloped. There's no PSR-11 equivalent, no standard container interface, and the whole concept of "final" doesn't exist.

But this isn't necessarily worse—it's different. TypeScript's structural typing and flexibility enable patterns that would be impossible in PHP. The lack of standardization has led to innovation, with each library exploring different approaches.

The key is to embrace these differences rather than fight them. Start simple, leverage structural typing, and only add DI complexity when you genuinely need it. Remember: in TypeScript, the best dependency injection might be no dependency injection framework at all.

Further Reading