Adapter
Intermediate · 10 min read · ▶ live playground · ✦ checkpoint
A payment succeeds in the provider's test console but never reaches checkout. Your app expects PaymentGateway.charge(amountDollars), while the provider's SDK exposes chargeCard(cents) and returns different field names.
The Adapter pattern makes an incompatible interface fit the one a client already expects. It protects the client from a vendor-specific API, but the translation becomes another boundary to maintain and test.
Before: the client and SDK speak different contracts
Checkout already depends on a stable PaymentGateway contract. The third-party SDK can charge the same card, but TypeScript cannot treat it as that gateway because its method name, units, and result shape differ.
type PaymentResult = {
transactionId: string;
approved: boolean;
};
interface PaymentGateway {
charge(amountDollars: number): PaymentResult;
}
class Checkout {
private gateway: PaymentGateway;
public constructor(gateway: PaymentGateway) {
this.gateway = gateway;
}
public complete(amountDollars: number): boolean {
return this.gateway.charge(amountDollars).approved;
}
}
type SdkCharge = {
id: string;
status: "captured" | "rejected";
amountInCents: number;
};
class ThirdPartyCardSdk {
public chargeCard(cents: number): SdkCharge {
return {
id: "card_" + cents,
status: cents > 0 ? "captured" : "rejected",
amountInCents: cents,
};
}
}Passing new ThirdPartyCardSdk() to Checkout would fail type checking. Renaming Checkout.complete() to call chargeCard() would make a stable application service depend on one vendor's vocabulary. It would also force checkout to remember that this provider accepts cents rather than dollars.
The two APIs are not wrong. They were designed on opposite sides of a boundary you do not control.
After: translate at the boundary
The target is the interface the client expects—in this case, PaymentGateway. The adaptee is the existing incompatible object you need to use—ThirdPartyCardSdk. The adapter implements the target and translates each call to the adaptee's shape.
class diagram
An adapter puts the card SDK behind PaymentGateway
- 1PaymentGateway is the target: the stable contract Checkout already understands.
- 2Checkout calls only the target, so provider-specific names stay outside the client.
- 3CardSdkAdapter implements the target and owns the translation between both shapes.
- 4The adaptee accepts cents; the adapter converts the call before delegating to it.
Checkout still sees the contract it was built around. The adapter stores the SDK, translates dollars to cents, delegates the charge, and maps the SDK response back into the application's result type.
type PaymentResult = {
transactionId: string;
approved: boolean;
amountDollars: number;
};
interface PaymentGateway {
charge(amountDollars: number): PaymentResult;
}
type SdkCharge = {
id: string;
status: "captured" | "rejected";
amountInCents: number;
};
class ThirdPartyCardSdk {
public chargeCard(cents: number): SdkCharge {
return {
id: "card_" + cents,
status: cents > 0 ? "captured" : "rejected",
amountInCents: cents,
};
}
}
class CardSdkAdapter implements PaymentGateway {
private sdk: ThirdPartyCardSdk;
public constructor(sdk: ThirdPartyCardSdk) {
this.sdk = sdk;
}
public charge(amountDollars: number): PaymentResult {
const cents = Math.round(amountDollars * 100);
const sdkCharge = this.sdk.chargeCard(cents);
return {
transactionId: sdkCharge.id,
approved: sdkCharge.status === "captured",
amountDollars: sdkCharge.amountInCents / 100,
};
}
}
class Checkout {
private gateway: PaymentGateway;
public constructor(gateway: PaymentGateway) {
this.gateway = gateway;
}
public complete(amountDollars: number): string {
const payment = this.gateway.charge(amountDollars);
if (!payment.approved) {
return `Declined $${amountDollars.toFixed(2)}`;
}
return (
`Paid $${payment.amountDollars.toFixed(2)}` +
` as ${payment.transactionId}`
);
}
}
const sdk = new ThirdPartyCardSdk();
const checkout = new Checkout(new CardSdkAdapter(sdk));
console.log(checkout.complete(24.5));Paid $24.50 as card_2450
The conversion happens in both directions. The outgoing call changes dollars and charge() into cents and chargeCard(). The incoming result changes status and id into approved and transactionId. Checkout owns neither translation.
That boundary also gives the mapping one focused test surface. A rounding rule, provider error code, or renamed SDK field changes the adapter rather than every payment client.
Object adapter versus class adapter
An object adapter stores an adaptee object and delegates to it through composition. CardSdkAdapter is an object adapter: the SDK arrives through its constructor and remains a field.
A class adapter inherits from the adaptee while also conforming to the target. That form depends on inheritance being available and useful. TypeScript classes can extend only one class, and third-party SDKs may hide construction or internal behavior that subclasses should not depend on.
TypeScript therefore tends to favor object adapters. Composition lets you wrap an existing SDK instance, substitute a test double, or choose a different SDK version without making the adapter a subtype of vendor code. The cost is one more object and one more delegation hop.
When NOT to use it
If you own both sides of a small mismatch, change the interface or the caller directly. An adapter between two modules you can update together can preserve an obsolete contract and make a two-line rename look architectural.
Adapters also accrete during migrations. OldGatewayAdapter, V2GatewayAdapter, and TemporaryGatewayAdapter can become permanent cruft if nobody owns a deletion condition. When an adapter exists only for a transition, record which callers must migrate and when the old shape can disappear.
Do not use an adapter to pretend two operations mean the same thing when their guarantees differ. A provider that only queues a payment is not equivalent to a gateway that promises captured funds. Translation can reconcile names and representations; it cannot erase a semantic mismatch.
Checkpoint
Answer all three to mark this lesson complete
Adapter gives an existing mismatch a clean boundary. Next, Bridge uses a related composition move earlier, before two independent variation axes multiply into subclasses.