Adapter Pattern
Make incompatible function signatures work together without modifying either side.
The Problemβ
You're building a map app (let's call it ViaMikeline) that shows points of interest from two open data sources. The IRVE API (EV charging stations) returns flat JSON with { nom_station, consolidated_latitude, consolidated_longitude, puissance_nominale, prise_type_2 }. The fuel station API returns nested JSON with { geom: { lon, lat }, gazole_prix, sp95_prix, carburants_disponibles }. Your map expects a single { name, coords: [lat, lng], meta } format.
The naive approach:
function toMapFeature(source: "charging" | "fuel", raw: unknown) {
if (source === "charging") {
const s = raw as IRVEStation;
return { name: s.nom_station, coords: [s.consolidated_latitude, s.consolidated_longitude], meta: { power: s.puissance_nominale } };
}
if (source === "fuel") {
const f = raw as FuelStation;
return { name: f.adresse, coords: [f.geom.lat, f.geom.lon], meta: { prix: f.gazole_prix } };
}
}
Conversion logic scattered in the UI. Adding a third source means more conditionals everywhere.
The Solutionβ
adapt(source, mapInput, mapOutput) wraps a function by transforming its input before calling it and its output after. Each API has its own raw fetch function, URL format, and response shape. adapt bridges the gap so the consumer sees a single uniform signature: (bbox: BBox) => Promise<MapFeature[]>.
import { adapt } from "@pithos/core/eidos/adapter/adapter";
interface MapFeature {
id: string;
name: string;
coords: [number, number];
meta: Record<string, string | number>;
}
// Raw fetch functions β each returns its own API-specific shape
async function rawFetchCharging(url: string): Promise<IRVEStation[]> { /* ... */ }
async function rawFetchFuel(url: string): Promise<FuelStation[]> { /* ... */ }
// adapt() wraps each raw fetch:
// mapInput: BBox β API-specific URL (different query format per API)
// mapOutput: raw records β MapFeature[] (different field mapping per API)
const fetchChargingPOIs = adapt(
rawFetchCharging, // source: (url: string) => Promise<IRVEStation[]>
(bbox: BBox): string => { // mapInput: BBox β IRVE URL format
const where = `in_bbox(coordonneesxy, ${bbox.west}, ${bbox.south}, ${bbox.east}, ${bbox.north})`;
return `https://odre.opendatasoft.com/api/...?where=${where}`;
},
async (records): Promise<MapFeature[]> => // mapOutput: IRVEStation[] β MapFeature[]
(await records).map((s) => ({
id: s.id_station_itinerance,
name: s.nom_station,
coords: [s.consolidated_latitude, s.consolidated_longitude],
meta: { power: `${s.puissance_nominale} kW` },
})),
);
const fetchFuelPOIs = adapt(
rawFetchFuel, // source: (url: string) => Promise<FuelStation[]>
(bbox: BBox): string => { // mapInput: BBox β Fuel URL format
const where = `in_bbox(geom, ${bbox.south}, ${bbox.west}, ${bbox.north}, ${bbox.east})`;
return `https://data.economie.gouv.fr/api/...?where=${where}`;
},
async (records): Promise<MapFeature[]> => // mapOutput: FuelStation[] β MapFeature[]
(await records).map((f) => ({
id: `fuel-${f.id}`,
name: `Station ${f.ville ?? "?"}`,
coords: [f.geom.lat, f.geom.lon],
meta: { gazole: f.gazole_prix ?? "β" },
})),
);
// Consumer code β same signature, doesn't know which API is behind
const charging = await fetchChargingPOIs(bbox); // MapFeature[]
const fuel = await fetchFuelPOIs(bbox); // MapFeature[]
Both adapted functions have the same signature: (bbox: BBox) => Promise<MapFeature[]>. The IRVE API expects in_bbox(coordonneesxy, west, south, east, north) while the fuel API expects in_bbox(geom, south, west, north, east): different parameter orders, different field names, different response shapes. adapt handles both sides: the input conversion (BBox β URL) and the output conversion (raw records β MapFeature). Adding a third data source means one new adapt() call, zero changes downstream.
Live Demoβ
The map needs MapFeature[] from a BBox. Each API expects a different URL format: IRVE uses in_bbox(coordonneesxy, west, south, east, north), fuel uses in_bbox(geom, south, west, north, east). They also return different record shapes. adapt() wraps each raw fetch so both input (BBox β URL) and output (raw JSON β MapFeature[]) are converted in one place. The map calls fetchCharging(bbox) and fetchFuel(bbox) with the same signature, unaware of the API differences behind them.
In a real application, these adapters would live in a backend service that ingests data from multiple sources, normalizes it through adapters, and stores the consolidated MapFeature records in a database. The frontend would query a single unified API. Here, the adapters run client-side so you can see them in action without any server infrastructure.
- adapters.ts
- Usage
/**
* Adapter pattern: adapt() bridges two incompatible APIs into one uniform interface.
*
* - IRVE API (EV charging) has its own URL format and response shape
* - Prix-carburants API (fuel) has a different URL format and response shape
* - adapt(source, mapInput, mapOutput) wraps each so both share:
* (bbox: BBox) => Promise<MapFeature[]>
*/
import { adapt } from "@pithos/core/eidos/adapter/adapter";
import { uniqBy } from "@pithos/core/arkhe/array/uniq-by";
import type { BBox, MapFeature } from "./types";
// ββ Raw API shapes ββββββββββββββββββββββββββββββββββββββββββββββββββ
interface IRVEStation {
id_station_itinerance: string;
nom_station: string;
adresse_station: string;
nom_operateur: string;
nbre_pdc: number;
puissance_nominale: number;
prise_type_2: string | null;
prise_type_combo_ccs: string | null;
prise_type_chademo: string | null;
gratuit: string | null;
horaires: string | null;
condition_acces: string | null;
consolidated_latitude: number;
consolidated_longitude: number;
}
interface FuelStation {
id: number;
adresse: string | null;
ville: string | null;
departement: string | null;
geom: { lon: number; lat: number };
gazole_prix: number | null;
sp95_prix: number | null;
sp98_prix: number | null;
e10_prix: number | null;
e85_prix: number | null;
carburants_disponibles: string[] | null;
horaires_automate_24_24: string | null;
}
// ββ Raw fetch βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
async function rawFetchCharging(url: string): Promise<IRVEStation[]> {
const res = await fetch(url);
if (!res.ok) throw res;
return (await res.json()).results ?? [];
}
async function rawFetchFuel(url: string): Promise<FuelStation[]> {
const res = await fetch(url);
if (!res.ok) throw res;
return (await res.json()).results ?? [];
}
// ββ Transformers ββββββββββββββββββββββββββββββββββββββββββββββββββββ
function plugTypes(s: IRVEStation): string {
const plugs: string[] = [];
if (s.prise_type_2 === "True") plugs.push("Type 2");
if (s.prise_type_combo_ccs === "True") plugs.push("CCS");
if (s.prise_type_chademo === "True") plugs.push("CHAdeMO");
return plugs.join(", ") || "β";
}
function toChargingFeature(s: IRVEStation): MapFeature {
return {
id: s.id_station_itinerance,
name: s.nom_station,
kind: "charging",
coords: [s.consolidated_latitude, s.consolidated_longitude],
meta: {
operator: s.nom_operateur ?? "β",
address: s.adresse_station ?? "β",
plugs: plugTypes(s),
points: s.nbre_pdc,
power: `${s.puissance_nominale} kW`,
free: s.gratuit === "True" ? "Yes" : s.gratuit === "False" ? "No" : "β",
hours: s.horaires ?? "β",
access: s.condition_acces ?? "β",
},
};
}
function toFuelFeature(s: FuelStation): MapFeature {
const prices: Record<string, number> = {};
if (s.gazole_prix != null) prices["Gazole"] = s.gazole_prix;
if (s.sp95_prix != null) prices["SP95"] = s.sp95_prix;
if (s.sp98_prix != null) prices["SP98"] = s.sp98_prix;
if (s.e10_prix != null) prices["E10"] = s.e10_prix;
if (s.e85_prix != null) prices["E85"] = s.e85_prix;
const best = Object.entries(prices).sort((a, b) => a[1] - b[1])[0];
return {
id: `fuel-${s.id}`,
name: `Station ${s.ville ?? "?"}`,
kind: "fuel",
coords: [s.geom.lat, s.geom.lon],
meta: {
address: s.adresse ?? "β",
city: s.ville ?? "β",
department: s.departement ?? "β",
fuels: (s.carburants_disponibles ?? []).join(", ") || "β",
"best price": best ? `${best[1].toFixed(3)}β¬ (${best[0]})` : "β",
"24/7": s.horaires_automate_24_24 === "Oui" ? "Yes" : "No",
},
};
}
// ββ Adapted functions (the pattern) βββββββββββββββββββββββββββββββββ
export const adaptedFetchCharging = adapt(
rawFetchCharging,
(bbox: BBox): string => {
const where = `in_bbox(coordonneesxy, ${bbox.west}, ${bbox.south}, ${bbox.east}, ${bbox.north})`;
const select = "id_station_itinerance,nom_station,adresse_station,nom_operateur,nbre_pdc,puissance_nominale,prise_type_2,prise_type_combo_ccs,prise_type_chademo,gratuit,horaires,condition_acces,consolidated_latitude,consolidated_longitude";
return `https://odre.opendatasoft.com/api/explore/v2.1/catalog/datasets/bornes-irve/records?where=${encodeURIComponent(where)}&select=${select}&group_by=${select}&limit=100`;
},
async (records): Promise<MapFeature[]> =>
uniqBy((await records).map(toChargingFeature), (f) => f.id),
);
export const adaptedFetchFuel = adapt(
rawFetchFuel,
(bbox: BBox): string => {
const where = `in_bbox(geom, ${bbox.south}, ${bbox.west}, ${bbox.north}, ${bbox.east})`;
return `https://data.economie.gouv.fr/api/explore/v2.1/catalog/datasets/prix-des-carburants-en-france-flux-instantane-v2/records?where=${encodeURIComponent(where)}&limit=100`;
},
async (records): Promise<MapFeature[]> =>
uniqBy((await records).map(toFuelFeature), (f) => f.id),
);
import { useState, useCallback, useRef } from "react";
import { fetchCharging, fetchFuels } from "@/lib/cache";
import { SOURCES } from "@/data/sources";
import type { MapFeature, SourceType, FeatureKind, BBox } from "@/lib/types";
export function useAdapterMap() {
const [charging, setCharging] = useState<MapFeature[]>([]);
const [fuels, setFuels] = useState<MapFeature[]>([]);
const [fallback, setFallback] = useState({ charging: false, fuel: false });
const [loadingCharging, setLoadingCharging] = useState(false);
const [loadingFuel, setLoadingFuel] = useState(false);
const [activeSources, setActiveSources] = useState<Set<SourceType>>(new Set(SOURCES));
const [mobileTab, setMobileTab] = useState<"map" | "list">("map");
const [popupOpen, setPopupOpen] = useState(false);
const [listOpen, setListOpen] = useState(false);
const [listTab, setListTab] = useState<FeatureKind>("charging");
const fetchRef = useRef(0);
const handleBoundsChange = useCallback((bbox: BBox) => {
const id = ++fetchRef.current;
setLoadingCharging(true);
fetchCharging(bbox).then((result) => {
if (id !== fetchRef.current) return;
setCharging(result.features);
setFallback((prev) => ({ ...prev, charging: result.fallback }));
setLoadingCharging(false);
});
setLoadingFuel(true);
fetchFuels(bbox).then((result) => {
if (id !== fetchRef.current) return;
setFuels(result.features);
setFallback((prev) => ({ ...prev, fuel: result.fallback }));
setLoadingFuel(false);
});
}, []);
const toggleSource = useCallback((source: SourceType) => {
setActiveSources((prev) => {
const next = new Set(prev);
if (next.has(source)) next.delete(source);
else next.add(source);
return next;
});
}, []);
const visibleFeatures: MapFeature[] = [
...(activeSources.has("charging") ? charging : []),
...(activeSources.has("fuel") ? fuels : []),
];
return {
charging, fuels, fallback, loadingCharging, loadingFuel,
activeSources, toggleSource, visibleFeatures,
mobileTab, setMobileTab, popupOpen, setPopupOpen,
listOpen, setListOpen, listTab, setListTab,
handleBoundsChange,
};
}
Real-World Analogyβ
A power adapter for international travel. Your laptop has a US plug, the outlet is European. The adapter doesn't change either. It just makes them compatible.
When to Use Itβ
- Integrate third-party APIs with incompatible response formats
- Normalize multiple data sources to a common shape
- Wrap legacy code with a modern interface
- Isolate conversion logic in one place
When NOT to Use Itβ
If you control both sides of the interface, just make them match. An adapter between two APIs you own is a sign that one of them should change.
APIβ
- adapt β Transform function arguments before calling
- createAdapter β Build reusable adapters for interface conversion
ViaMichelin is a registered trademark of Michelin. It is mentioned here solely as a real-world example for educational purposes under nominative fair use. This project is not affiliated with, endorsed by, or sponsored by Michelin or ViaMichelin in any way.