Definition
An adapter converts the interface of an existing class into the interface its clients expect, so that classes with incompatible interfaces can work together.
After Gamma et al., Design Patterns (1994), Adapter
01Recognize the mismatch
The first provider is easy to call directly.
A single meter call is clear: lookup(plate, curb). Its response already names the
state the dashboard needs, so a small dashboard can branch on that result without extra
machinery. Here is that dashboard, as a fragment: the meter’s types appear in the full
program below.
// The dashboard asks the legacy meter directly.
function showPermit(meter: LegacyMeter, plate: string, zone: string): string {
const reply = meter.lookup(plate, zone);
if (reply.state === 'active') {
return `${plate} in ${zone}: permit through ${reply.end}`;
}
if (reply.state === 'none') return `${plate} in ${zone}: no active permit`;
return `${plate} in ${zone}: check again, source unavailable`;
} Then the payment partner arrives. It calls the area a district, the vehicle a vehicle, and the expiry paidThrough, and it answers with
HTTP-shaped status codes. If the dashboard, sign view, and enforcement export each learn
those fields and status codes, each caller now owns a piece of provider translation.
An adapter lets an existing object serve the interface a caller expects. Here each adapter accepts the dashboard’s lookupPermit(plate, zone) request, delegates to one
meter API, and returns a permit, no permit, or a useful error.
A travel plug adapter makes the relationship easy to picture: it lets one connector fit another socket. In software, fitting method names is only part of the job. The wrapper must preserve what the source can actually say.
You already use adapters like this:
| A familiar adapter | What it takes | What it gives the caller |
|---|---|---|
| util.promisify(fn) | A callback-last function | A function returning a Promise. |
| Readable.toWeb(stream) | A Node.js stream | A web ReadableStream (Node 17+). |
| useSyncExternalStore(subscribe, getSnapshot) | Any outside store | React’s rendering (React 18+). |
02Translate at the boundary
Keep the question. Translate the answers.
Each adapter holds one source and implements the dashboard’s PermitLookup.
The legacy adapter maps the meter’s registration, curb and end onto the dashboard’s plate,
zone and expiresAt; the partner adapter maps vehicle, district and paidThrough. Each also translates outcomes: a source’s own signal for no
permit becomes null, and a source that cannot answer becomes PermitUnavailable. The sources in this program are memory doubles whose
replies are already decoded, so the program isolates the boundary mapping. This is the whole
program the lab runs: two sources, their adapters, and the dashboard.
// The dashboard's contract: a permit, null for "no permit",
// or PermitUnavailable when the source cannot answer.
type Permit = { plate: string; zone: string; expiresAt: string };
class PermitUnavailable extends Error {}
interface PermitLookup {
lookupPermit(plate: string, zone: string): Permit | null;
}
// Two existing APIs, as they already are.
type LegacyReply = {
state: string;
registration: string;
curb: string;
end: string;
};
interface LegacyMeter {
lookup(plate: string, curb: string): LegacyReply;
}
type PartnerReply = {
status: number;
vehicle: string;
district: string;
paidThrough: string;
};
interface PartnerMeter {
fetchPermit(plate: string, zone: string): PartnerReply;
}
// Each adapter holds one source and answers the dashboard's question with it.
class LegacyPermitAdapter implements PermitLookup {
constructor(private source: LegacyMeter) {}
lookupPermit(plate: string, zone: string): Permit | null {
const reply = this.source.lookup(plate, zone);
if (reply.state === 'none') return null;
if (reply.state !== 'active') throw new PermitUnavailable(reply.state);
const { registration, curb, end } = reply;
return { plate: registration, zone: curb, expiresAt: end };
}
}
class PartnerPermitAdapter implements PermitLookup {
constructor(private source: PartnerMeter) {}
lookupPermit(plate: string, zone: string): Permit | null {
const reply = this.source.fetchPermit(plate, zone);
if (reply.status === 404) return null;
if (reply.status !== 200) {
throw new PermitUnavailable(`status ${reply.status}`);
}
const { vehicle, district, paidThrough } = reply;
return { plate: vehicle, zone: district, expiresAt: paidThrough };
}
}
// The dashboard knows PermitLookup and no provider.
function showPermit(reader: PermitLookup, plate: string, zone: string): string {
try {
const permit = reader.lookupPermit(plate, zone);
if (!permit) return `${plate} in ${zone}: no active permit`;
return `${permit.plate} in ${permit.zone}: permit through ${permit.expiresAt}`;
} catch {
return `${plate} in ${zone}: check again, source unavailable`;
}
}
// What each provider says about zone C7 today, in memory.
const legacy: LegacyMeter = {
lookup: (plate) =>
plate === 'KLM-204'
? { state: 'active', registration: plate, curb: 'C7', end: '18:30' }
: { state: 'none', registration: '', curb: '', end: '' },
};
const blank = { vehicle: '', district: '', paidThrough: '' };
const partner: PartnerMeter = {
fetchPermit: (plate) =>
plate === 'KLM-204'
? { status: 200, vehicle: plate, district: 'C7', paidThrough: '18:30' }
: plate === 'RTE-118'
? { status: 429, ...blank } // too many requests: try later
: { status: 404, ...blank },
};
const partnerLookup = new PartnerPermitAdapter(partner);
const fromLegacy = showPermit(new LegacyPermitAdapter(legacy), 'KLM-204', 'C7');
const fromPartner = showPermit(partnerLookup, 'KLM-204', 'C7');
const noPermit = showPermit(partnerLookup, 'BXT-551', 'C7');
const rateLimited = showPermit(partnerLookup, 'RTE-118', 'C7');
console.log(`[legacy] ${fromLegacy}`);
console.log(`[partner] ${fromPartner}`);
console.log(`[partner] ${noPermit}`);
console.log(`[partner] ${rateLimited}`); | Role | Responsibility | What it handles |
|---|---|---|
| Caller → target | “Find this permit.” | The dashboard passes a plate and curb zone, then handles a permit, no permit, or failure. |
| Adapter → source | Translate the exchange. | The adapter changes argument names, response fields, and provider outcomes into the dashboard contract. |
| Source → existing API | Keep its own shape. | Each meter keeps its established API; it does not need to know how the dashboard is written. |
| The dashboard gets | Legacy meter | Payment partner |
|---|---|---|
| Found | active + registration, curb and end | 200 + vehicle, district and paidThrough |
| No permit | none | 404 |
| Source unavailable | down, or a failed call | Any other status, or a failed call |
| Invalid response (production) | Another state, or a row for another plate or curb | A 200 for another vehicle or district, or with no time |
What a production adapter addsInput checks, failed calls, and replies that answer another question
The same adapters, checked. An empty plate or zone is refused before the source is called.
A call that fails is unavailable, like a meter that is down. And a successful reply must
answer the question asked: the meter’s row and the partner’s record must name the
requested plate and zone and carry an end time, or the reply is an invalid response, InvalidPermitResponse. A missing or mismatched success is never turned into
absence: an adapter that cannot read an answer has not been told there is no permit. Only active can carry a permit; an unknown legacy state is invalid even when its row
happens to match.
export type Permit = Readonly<{ plate: string; zone: string; expiresAt: string }>;
/** The source could not answer: ask again later. */
export class PermitUnavailable extends Error {}
/** The source answered, but not about this plate and zone, or not completely. */
export class InvalidPermitResponse extends Error {}
export interface PermitLookup {
lookupPermit(plate: string, zone: string): Permit | null;
}
export type LegacyReply = { state: string; registration: string; curb: string; end: string };
export interface LegacyMeter {
lookup(plate: string, curb: string): LegacyReply;
}
export type PartnerReply = {
status: number;
vehicle: string;
district: string;
paidThrough: string;
};
export interface PartnerMeter {
fetchPermit(plate: string, zone: string): PartnerReply;
}
function requireInput(plate: string, zone: string) {
if (!plate || !zone) throw new TypeError('plate and zone must not be empty');
}
export class LegacyPermitAdapter implements PermitLookup {
constructor(private readonly source: LegacyMeter) {}
lookupPermit(plate: string, zone: string): Permit | null {
requireInput(plate, zone);
let reply: LegacyReply;
try {
reply = this.source.lookup(plate, zone);
} catch (cause) {
throw new PermitUnavailable('legacy meter call failed', { cause });
}
if (reply.state === 'none') return null;
if (reply.state === 'down') throw new PermitUnavailable('legacy meter is down');
// Only "active" carries a permit; any other state is one we cannot read.
if (reply.state !== 'active') throw new InvalidPermitResponse(`state ${reply.state}`);
if (reply.registration !== plate || reply.curb !== zone || !reply.end) {
throw new InvalidPermitResponse('row does not match the request');
}
return Object.freeze({ plate, zone, expiresAt: reply.end });
}
}
export class PartnerPermitAdapter implements PermitLookup {
constructor(private readonly source: PartnerMeter) {}
lookupPermit(plate: string, zone: string): Permit | null {
requireInput(plate, zone);
let reply: PartnerReply;
try {
reply = this.source.fetchPermit(plate, zone);
} catch (cause) {
throw new PermitUnavailable('partner call failed', { cause });
}
if (reply.status === 404) return null;
if (reply.status !== 200) throw new PermitUnavailable(`partner status ${reply.status}`);
if (reply.vehicle !== plate || reply.district !== zone || !reply.paidThrough) {
throw new InvalidPermitResponse('record does not match the request');
}
return Object.freeze({ plate, zone, expiresAt: reply.paidThrough });
}
} These sources return typed in-memory responses. A real integration also needs runtime decoding, authentication, deadlines, cancellation, diagnostics, and a target contract that represents waiting. The adapter cannot make a stale meter current or turn an unavailable provider into a reliable answer.
The direct-call version was a good first step. The adapter earns its place when several callers need the same permit meaning and the second provider would otherwise spread its vocabulary through them.
03Predict what the dashboard shows
Change one check. Follow one lookup.
Experiment
One dashboard, two meter APIs
// Lines 45–48 of the partner adapter become:
if (reply.status === 503) throw new PermitUnavailable('status 503');
if (!reply.paidThrough) return null; Reading the TypeScriptimplements, parameter properties, and error classes
implements PermitLookup is checked when the code compiles and leaves
nothing behind at run time. TypeScript’s types are structural, so anything with a
matching lookupPermit fits the contract: the program’s in-memory sources are plain
object literals that fit LegacyMeter and PartnerMeter the same way.
constructor(private source: LegacyMeter) {} is a parameter property: it
declares the field and assigns it in one line. Node’s built-in type stripping (--experimental-strip-types, Node 22.6 and later) rejects it, because it is TypeScript that emits code; tsc, esbuild and the lab’s runner accept it.
class PermitUnavailable extends Error {} gives the failure a type the
dashboard can test with instanceof, once the code targets ES2015 or later.
The dashboard’s catch { without a binding (ES2019) catches anything, which
is fine while the contract has one failure kind; the production version adds a second.
04Meet it in your language
Your standard library already adapts things.
In Node, util.promisify is an adapter you call by name: it takes a function
whose last argument is a callback and returns one that returns a promise, the contract await expects. The bug is handing it a method without its object. promisify(client.lookup) receives the function alone and calls it with no this, so the first line that reads this.baseUrl throws, and the
promise rejects with a TypeError.
import { promisify } from 'node:util';
// A callback-style client, as many older Node SDKs are.
class MeterClient {
constructor(private baseUrl: string) {}
lookup(plate: string, done: (error: Error | null, state: string) => void) {
const url = `${this.baseUrl}/permits/${plate}`;
// The real client sends a request to url; this one answers at once.
setImmediate(() => done(null, `active (${url})`));
}
}
const client = new MeterClient('https://meters.example');
// Before: promisify receives the method alone. Called without its object,
// lookup runs with `this` undefined, and the promise rejects with a TypeError.
export const lookupBefore = promisify(client.lookup);
// After: hand the adapter the object as well as the method.
export const lookup = promisify(client.lookup.bind(client)); Hand the adapter the object as well as the method, with bind or an arrow
function, for the same reason the lesson’s adapters hold their source rather than one of
its functions. typescript-eslint’s unbound-method rule, in its type-checked
configurations, reports the first form. Before adapting, check whether the library already
ships a promise API, as node:fs/promises does.
In the frontend useSyncExternalStore, and Svelte’s readable Optional
React reads data that lives outside it through one contract: useSyncExternalStore(subscribe, getSnapshot) (React 18 and later). The
browser’s geolocation API speaks another: watchPosition hands back an ID and calls
you back with each position. The store below is the adapter between them.
The bug is where it translates. In the “before” version, getSnapshot builds
the state object when React reads it, so every read returns a new object. React compares
snapshots with Object.is, sees a change on every read, warns “The result of
getSnapshot should be cached to avoid an infinite loop” and re-renders until it stops with
“Maximum update depth exceeded”. Translate when the browser reports, and let reads return
that value.
import { useSyncExternalStore } from 'react';
// React's contract for outside data: subscribe(onChange) returns an
// unsubscribe, and getSnapshot() returns the same value until it changes.
// The browser's geolocation API is a watch ID and callbacks.
type Position =
| { status: 'locating' | 'unavailable' }
| { status: 'located'; meters: number };
type PositionStore = {
subscribe(onChange: () => void): () => void;
getSnapshot(): Position;
};
type Report = (position: GeolocationPosition | null) => void;
function watch(geo: Geolocation, report: Report) {
const id = geo.watchPosition(report, () => report(null));
return () => geo.clearWatch(id);
}
// Before: the adapter translates when React reads. Every call makes a new
// object, so React sees a change on every read and re-renders without end.
export function positionStoreBefore(geo: Geolocation): PositionStore {
let last: GeolocationPosition | null | undefined;
return {
subscribe: (onChange) =>
watch(geo, (position) => {
last = position;
onChange();
}),
getSnapshot: () =>
last === undefined
? { status: 'locating' }
: last === null
? { status: 'unavailable' }
: { status: 'located', meters: last.coords.accuracy },
};
}
// After: translate once, when the browser reports; reads return that value.
export function positionStore(geo: Geolocation): PositionStore {
let snapshot: Position = { status: 'locating' };
return {
subscribe: (onChange) =>
watch(geo, (position) => {
snapshot = position
? { status: 'located', meters: position.coords.accuracy }
: { status: 'unavailable' };
onChange();
}),
getSnapshot: () => snapshot,
};
}
// The app makes one store, outside any component, and passes it down.
export function Accuracy({ store }: { store: PositionStore }) {
const position = useSyncExternalStore(store.subscribe, store.getSnapshot);
if (position.status !== 'located') {
return <p>{position.status === 'locating' ? 'Finding you…' : 'Enter an address.'}</p>;
}
return <p>Located to within {Math.round(position.meters)} m</p>;
} Svelte’s store contract pushes values to subscribers instead of being read, so the
snapshot rule does not arise. readable(initial, start) is the adapter: Svelte
calls start when the first subscriber arrives and the function it returns when the
last one leaves, which is where clearWatch goes.
05Decide when it helps
Name the mismatch you are removing.
An adapter is useful when a caller has a coherent contract and an existing dependency offers a different one that you cannot or should not change. It localizes that mismatch and adds a mapping to keep correct as either contract changes.
| The change | Each caller translates | One adapter per source |
|---|---|---|
| A third source arrives with its own API | Every caller learns its request format, its reply and its failures. | One new adapter. No caller changes. |
| The partner renames paidThrough | Every caller that reads it changes. | One line in one adapter. |
| The display must tell an outage from bad data | Each caller learns each source’s failure codes. | The contract gains a failure kind; each adapter maps its source onto it. |
| A new source already answers lookupPermit(plate, zone) | Call it directly. | An adapter would only forward the call: skip it. |
| Need | Choice | Responsibility |
|---|---|---|
| Use either meter through the permit lookup | Adapter | Translate an existing interface into the caller’s contract. |
| Coordinate several setup steps as one operation | Facade | Offer a focused operation over a subsystem workflow. |
| Add behavior around an already compatible lookup | Decorator | Keep the interface while adding behavior around its use. |
| Choose a replaceable permit policy | Strategy | Supply another way to perform the same policy responsibility. |
| Source already fits the caller | Direct call | Keep it visible; avoid a wrapper that only renames a method. |
Its neighbours: Facade coordinates a subsystem workflow; Decorator adds behaviour around a compatible interface; Proxy controls access to an object; Strategy supplies a replaceable policy; and ports and adapters makes this lesson’s move the shape of a whole application.
What translation cannot decideA provider guarantee has to come from the provider
The adapter can map the provider’s explicit “no permit” response to absence. It cannot infer absence from a timeout, decide whether an expired permit deserves a grace period, or make provider data fresher. Keep those policies with the owner that has enough information to make them.
Is this only a data converter?Follow the request and the responsibility
A field-mapping function can be one part of an adapter. The full relationship includes the caller’s request, the existing source operation, and the returned target contract. The dashboard asks one question; the wrapper delegates and interprets the source’s reply.
Choosing which adapter to supply is composition. The objects are adapters because they wrap existing interfaces that do not fit the dashboard.
06Make the call
Translate, or decide?
One more, in another setting: the same move, somewhere this lesson has not been.
Before you open the answer below, say it in your own words: what does an adapter translate, and what must it refuse to decide?
One way to say itWithout the pattern’s name
The dashboard asks one permit question. A wrapper calls the provider’s API and returns what that API can honestly establish. It translates the request on the way in and the reply on the way out: names, shapes and units, and also outcomes, so a provider’s own “no permit” becomes absence, a provider that cannot answer becomes an outage, and a reply about another car or zone becomes an invalid response, never a permit. It refuses to decide anything the provider did not say: absence inferred from a timeout or an empty record, a grace period, an old answer served as current. Those are policies for the caller, or for a wrapper of their own around the lookup. And when a source already fits the caller’s contract, there is no mismatch to remove, so there is no adapter to write.
Practice in the lab
The dashboard from the lesson: it asks one question, “is there a permit for this plate in this zone, and when does it end?”, through PermitLookup, and an adapter answers it for each source. The city adds its resident-permit registry as a third. Each level starts from the previous level’s finished code, so you can begin at any of them.
- ADAPTER-101 (easy) Residents with permits are shown no permit
- ADAPTER-102 (medium) Another car’s permit, shown as this one’s Pro
- ADAPTER-103 (hard) Review: fewer check-again screens Pro