Adapter Pattern in TypeScript: Interface Adapters
The adapter pattern is a structural design pattern that acts as a bridge between two incompatible interfaces. Think of it like a power adapter when traveling internationally—your laptop's plug...
Key Insights
- The adapter pattern creates a translation layer between incompatible interfaces, allowing your application code to remain stable while external dependencies change freely
- TypeScript’s type system makes adapters particularly powerful—you get compile-time guarantees that your adapter correctly implements the target interface
- Combining adapters with factory patterns enables runtime selection of implementations, making your system extensible without modifying existing code
Introduction to the Adapter Pattern
The adapter pattern is a structural design pattern that acts as a bridge between two incompatible interfaces. Think of it like a power adapter when traveling internationally—your laptop’s plug doesn’t fit the foreign outlet, but an adapter makes them work together without modifying either.
In software, you encounter this constantly. Your application defines an interface it expects, but the library or service you need to integrate has a completely different API shape. You have three options: modify your application code (risky and couples you tightly), modify the external code (usually impossible), or create an adapter (clean and maintainable).
Here’s the core concept in its simplest form:
// What your application expects
interface Logger {
log(message: string): void;
}
// What the third-party library provides
class FancyLogger {
writeToConsole(msg: string, timestamp: Date): void {
console.log(`[${timestamp.toISOString()}] ${msg}`);
}
}
// The adapter bridges the gap
class FancyLoggerAdapter implements Logger {
constructor(private fancyLogger: FancyLogger) {}
log(message: string): void {
this.fancyLogger.writeToConsole(message, new Date());
}
}
Your application code continues using Logger. The adapter handles the translation. When FancyLogger updates its API, you fix one adapter class instead of hunting through your entire codebase.
Real-World Problem Setup
Let’s tackle a realistic scenario: payment processing. Your e-commerce application has a well-defined payment interface, but you need to integrate Stripe’s API, which has its own conventions and method signatures.
First, your application’s expected interface:
interface PaymentResult {
success: boolean;
transactionId: string;
errorMessage?: string;
}
interface PaymentProcessor {
charge(amount: number, currency: string, customerId: string): Promise<PaymentResult>;
refund(transactionId: string, amount?: number): Promise<PaymentResult>;
getTransaction(transactionId: string): Promise<TransactionDetails | null>;
}
interface TransactionDetails {
id: string;
amount: number;
currency: string;
status: 'pending' | 'completed' | 'failed' | 'refunded';
createdAt: Date;
}
Now, the external Stripe API (simplified for illustration):
// This represents Stripe's actual API shape - we can't modify it
class StripeAPI {
async createCharge(params: {
amount_cents: number;
currency_code: string;
customer_token: string;
idempotency_key?: string;
}): Promise<StripeCharge> {
// Stripe implementation
return {
id: `ch_${Date.now()}`,
amount_cents: params.amount_cents,
currency_code: params.currency_code,
status: 'succeeded',
created_at: Math.floor(Date.now() / 1000),
};
}
async createRefund(params: {
charge_id: string;
amount_cents?: number;
}): Promise<StripeRefund> {
return {
id: `rf_${Date.now()}`,
charge_id: params.charge_id,
status: 'succeeded',
};
}
async retrieveCharge(chargeId: string): Promise<StripeCharge | null> {
// Stripe implementation
return null;
}
}
interface StripeCharge {
id: string;
amount_cents: number;
currency_code: string;
status: 'succeeded' | 'pending' | 'failed';
created_at: number; // Unix timestamp
}
interface StripeRefund {
id: string;
charge_id: string;
status: 'succeeded' | 'pending' | 'failed';
}
Notice the mismatches: Stripe uses amount_cents (integers) while our interface uses amount (decimals). Stripe uses customer_token while we use customerId. Stripe returns Unix timestamps while we expect Date objects. These differences are exactly what adapters solve.
Implementing the Class Adapter
The adapter implements your target interface while internally delegating to the external API. I strongly recommend composition over inheritance here—wrap the external class rather than extending it.
class StripeAdapter implements PaymentProcessor {
constructor(private stripe: StripeAPI) {}
async charge(
amount: number,
currency: string,
customerId: string
): Promise<PaymentResult> {
try {
const charge = await this.stripe.createCharge({
amount_cents: this.toCents(amount),
currency_code: currency.toLowerCase(),
customer_token: customerId,
idempotency_key: this.generateIdempotencyKey(),
});
return {
success: charge.status === 'succeeded',
transactionId: charge.id,
};
} catch (error) {
return {
success: false,
transactionId: '',
errorMessage: error instanceof Error ? error.message : 'Unknown error',
};
}
}
async refund(transactionId: string, amount?: number): Promise<PaymentResult> {
try {
const refund = await this.stripe.createRefund({
charge_id: transactionId,
amount_cents: amount ? this.toCents(amount) : undefined,
});
return {
success: refund.status === 'succeeded',
transactionId: refund.id,
};
} catch (error) {
return {
success: false,
transactionId: '',
errorMessage: error instanceof Error ? error.message : 'Unknown error',
};
}
}
async getTransaction(transactionId: string): Promise<TransactionDetails | null> {
const charge = await this.stripe.retrieveCharge(transactionId);
if (!charge) return null;
return {
id: charge.id,
amount: this.fromCents(charge.amount_cents),
currency: charge.currency_code.toUpperCase(),
status: this.mapStatus(charge.status),
createdAt: new Date(charge.created_at * 1000),
};
}
private toCents(amount: number): number {
return Math.round(amount * 100);
}
private fromCents(cents: number): number {
return cents / 100;
}
private mapStatus(stripeStatus: string): TransactionDetails['status'] {
const statusMap: Record<string, TransactionDetails['status']> = {
succeeded: 'completed',
pending: 'pending',
failed: 'failed',
};
return statusMap[stripeStatus] ?? 'pending';
}
private generateIdempotencyKey(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
}
The adapter encapsulates all Stripe-specific logic: currency conversion, status mapping, timestamp handling. Your application code remains blissfully unaware of Stripe’s conventions.
Type Safety with TypeScript Generics
For reusable adapter patterns across your codebase, create a generic adapter interface:
interface Adapter<TSource, TTarget> {
adapt(source: TSource): TTarget;
}
// Async variant for API calls
interface AsyncAdapter<TSource, TTarget> {
adapt(source: TSource): Promise<TTarget>;
}
// Bidirectional adapter for two-way transformations
interface BidirectionalAdapter<TSource, TTarget> {
toTarget(source: TSource): TTarget;
toSource(target: TTarget): TSource;
}
// Example: Adapting Stripe charges to our transaction format
class StripeChargeAdapter implements Adapter<StripeCharge, TransactionDetails> {
adapt(source: StripeCharge): TransactionDetails {
return {
id: source.id,
amount: source.amount_cents / 100,
currency: source.currency_code.toUpperCase(),
status: this.mapStatus(source.status),
createdAt: new Date(source.created_at * 1000),
};
}
private mapStatus(status: string): TransactionDetails['status'] {
return status === 'succeeded' ? 'completed' : 'pending';
}
}
This generic approach lets TypeScript verify that your adapter correctly transforms the source type into the target type at compile time.
Multiple Adapters and Factory Pattern Integration
Real applications often support multiple payment providers. Combine adapters with a factory for clean runtime selection:
type PaymentProvider = 'stripe' | 'paypal' | 'square';
interface PaymentConfig {
provider: PaymentProvider;
apiKey: string;
environment: 'sandbox' | 'production';
}
class PaymentAdapterFactory {
private adapters: Map<PaymentProvider, PaymentProcessor> = new Map();
constructor(private configs: PaymentConfig[]) {
this.initializeAdapters();
}
private initializeAdapters(): void {
for (const config of this.configs) {
const adapter = this.createAdapter(config);
this.adapters.set(config.provider, adapter);
}
}
private createAdapter(config: PaymentConfig): PaymentProcessor {
switch (config.provider) {
case 'stripe':
return new StripeAdapter(new StripeAPI(/* config */));
case 'paypal':
return new PayPalAdapter(new PayPalSDK(/* config */));
case 'square':
return new SquareAdapter(new SquareClient(/* config */));
default:
throw new Error(`Unsupported payment provider: ${config.provider}`);
}
}
getAdapter(provider: PaymentProvider): PaymentProcessor {
const adapter = this.adapters.get(provider);
if (!adapter) {
throw new Error(`No adapter configured for provider: ${provider}`);
}
return adapter;
}
getDefaultAdapter(): PaymentProcessor {
const first = this.adapters.values().next().value;
if (!first) throw new Error('No payment adapters configured');
return first;
}
}
Your application code now works with any payment provider through a single interface:
const factory = new PaymentAdapterFactory(configs);
const processor = factory.getAdapter('stripe');
const result = await processor.charge(99.99, 'USD', 'cust_123');
Testing Adapter Implementations
Adapters are straightforward to test because they have clear input/output boundaries:
describe('StripeAdapter', () => {
let mockStripeAPI: jest.Mocked<StripeAPI>;
let adapter: StripeAdapter;
beforeEach(() => {
mockStripeAPI = {
createCharge: jest.fn(),
createRefund: jest.fn(),
retrieveCharge: jest.fn(),
} as unknown as jest.Mocked<StripeAPI>;
adapter = new StripeAdapter(mockStripeAPI);
});
describe('charge', () => {
it('converts dollars to cents correctly', async () => {
mockStripeAPI.createCharge.mockResolvedValue({
id: 'ch_123',
amount_cents: 9999,
currency_code: 'usd',
status: 'succeeded',
created_at: 1234567890,
});
await adapter.charge(99.99, 'USD', 'cust_123');
expect(mockStripeAPI.createCharge).toHaveBeenCalledWith(
expect.objectContaining({
amount_cents: 9999,
currency_code: 'usd',
})
);
});
it('maps successful charge to PaymentResult', async () => {
mockStripeAPI.createCharge.mockResolvedValue({
id: 'ch_123',
amount_cents: 1000,
currency_code: 'usd',
status: 'succeeded',
created_at: 1234567890,
});
const result = await adapter.charge(10, 'USD', 'cust_123');
expect(result).toEqual({
success: true,
transactionId: 'ch_123',
});
});
it('handles API errors gracefully', async () => {
mockStripeAPI.createCharge.mockRejectedValue(new Error('Card declined'));
const result = await adapter.charge(10, 'USD', 'cust_123');
expect(result.success).toBe(false);
expect(result.errorMessage).toBe('Card declined');
});
});
});
Tradeoffs and When to Avoid
The adapter pattern adds indirection. Before reaching for it, consider:
Use adapters when:
- Integrating third-party libraries with unstable or mismatched APIs
- You need to swap implementations without changing application code
- Multiple external services must conform to a single internal interface
- You want to isolate external API changes to a single location
Avoid adapters when:
- The external API closely matches your needs (just use it directly)
- You’re only integrating one service with no plans to swap
- The translation logic is trivial (a simple function might suffice)
- You’re adding adapters “just in case”—that’s speculative complexity
The facade pattern is a lighter alternative when you just want to simplify a complex API without interface translation. Direct integration works fine for stable, well-designed external APIs that you control or trust.
Adapters shine when external volatility meets internal stability requirements. They’re not free—you maintain another layer—but that cost pays dividends when the third-party SDK releases a breaking change and your fix is isolated to one class.