Definition
A bridge separates an abstraction from its implementation, so that each can vary without the other.
After Gamma et al., Design Patterns (1994), Bridge
01Let two things change
A chart should not need to know its pixels.
A function that ranks ticket categories and returns a small SVG string is a reasonable first implementation. Its data rules and its geometry live together because there is only one thing to draw.
type Point = { label: string; value: number };
const escapeXml = (text: string) =>
text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
// The dashboard's chart: rank the categories, keep three, draw them as SVG.
function topCategoriesSvg(title: string, points: Point[]): string {
const top = [...points].sort((a, b) => b.value - a.value).slice(0, 3);
const bars = top.map((p) => {
const width = Math.floor((p.value * 200) / top[0].value);
const tip = `${escapeXml(p.label)}: ${p.value}`;
return `<rect width="${width}"><title>${tip}</title></rect>`;
});
const head = `<title>${escapeXml(`Top categories: ${title}`)}</title>`;
const note = `<text>${top.length} of ${points.length} shown</text>`;
return `<svg>${head}${bars.join('')}${note}</svg>`;
} Now add a text version for the digest, then a weekly trend. Separate functions for each pairing can repeat the ranking in two places and the escaping in two others. A change to how many categories count as “top” can drift between the dashboard and the digest. The pressure comes from two kinds of change crossing the same code: two charts and two surfaces are four functions, and the next surface makes six.
Bridge separates a higher-level abstraction from the implementation it uses, so the two can vary independently. Here, each chart composes meaningful rows through a surface interface. Each surface turns those operations into one kind of drawing. The caller connects a chart to a surface.
The traditional names are abstraction for the chart side and implementor for the surface side. Both sides contain real implementation code. These names describe their relationship: the chart expresses higher-level behaviour using a smaller set of drawing operations.
02Connect meaning to drawing
The chart decides. The surface draws.
A surface can draw two things: one bar, and a figure around the bars. TopCategories ranks the categories and keeps three; WeeklyTrend keeps every week in order and notes the change from first to last. Neither asks which surface it holds. This is the whole program the lab runs: two surfaces, two charts, and a caller connecting three pairs.
// What every surface can draw: one bar, and a figure around the bars.
interface Surface {
bar(label: string, value: number, largest: number): string;
figure(title: string, bars: string[], note: string): string;
}
const escapeXml = (text: string) =>
text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
// Plain text for the weekly digest: a bar is up to 20 blocks.
class TextSurface implements Surface {
bar(label: string, value: number, largest: number): string {
const blocks = '█'.repeat(Math.floor((value * 20) / largest));
return `${label} ${blocks} ${value}`;
}
figure(title: string, bars: string[], note: string): string {
return [title, ...bars, note].join('\n');
}
}
// SVG for the dashboard: a bar is up to 200 units wide.
class SvgSurface implements Surface {
bar(label: string, value: number, largest: number): string {
const width = Math.floor((value * 200) / largest);
const tip = `${escapeXml(label)}: ${value}`;
return `<rect width="${width}"><title>${tip}</title></rect>`;
}
figure(title: string, bars: string[], note: string): string {
const head = `<title>${escapeXml(title)}</title>`;
return `<svg>${head}${bars.join('')}<text>${escapeXml(note)}</text></svg>`;
}
}
type Point = { label: string; value: number };
// Top categories decides what to show: the three biggest, largest first.
class TopCategories {
private readonly surface: Surface;
constructor(surface: Surface) {
this.surface = surface;
}
draw(title: string, points: Point[]): string {
const top = [...points].sort((a, b) => b.value - a.value).slice(0, 3);
const largest = top[0].value;
const bars = top.map((p) => this.surface.bar(p.label, p.value, largest));
const note = `${top.length} of ${points.length} shown`;
return this.surface.figure(`Top categories: ${title}`, bars, note);
}
}
// Weekly trend decides what to show: every week in order, and the change.
class WeeklyTrend {
private readonly surface: Surface;
constructor(surface: Surface) {
this.surface = surface;
}
draw(title: string, points: Point[]): string {
const largest = Math.max(...points.map((p) => p.value));
const bars = points.map((p) => this.surface.bar(p.label, p.value, largest));
const change = points[points.length - 1].value - points[0].value;
const note = `Change: ${change >= 0 ? '+' : ''}${change}`;
return this.surface.figure(`Weekly trend: ${title}`, bars, note);
}
}
// The caller connects one chart to one surface.
const categories: Point[] = [
{ label: 'Billing & refunds', value: 12 },
{ label: 'Login', value: 30 },
{ label: 'Exports', value: 7 },
{ label: 'Mobile', value: 18 },
];
const weeks: Point[] = [
{ label: 'W1', value: 20 },
{ label: 'W2', value: 26 },
{ label: 'W3', value: 21 },
];
const svg = new SvgSurface();
const text = new TextSurface();
const title = 'Support tickets';
const dashboard = new TopCategories(svg).draw(title, categories);
const digest = new TopCategories(text).draw(title, categories);
const trend = new WeeklyTrend(text).draw(title, weeks);
console.log(dashboard);
console.log(digest);
console.log(trend); | Decision | Owner | Reason |
|---|---|---|
| Which categories appear, in what order? | TopCategories | The answer should agree across surfaces. |
| What does the note say? | The chart | “3 of 4 shown” and the change from the first week to the last are part of the chart’s meaning. |
| How long is a bar? | The selected surface | Two hundred SVG units and twenty block characters are different drawings of the same ratio. |
| Which pair is drawn? | The caller | It hands a surface to the chosen chart. |
On either surface, Login leads the top categories and only three of four appear. Switching the surface changes the drawing while preserving those content decisions; switching the chart keeps the surface and changes the rows it is given.
What a production chart addsChecks before drawing, and the caller’s choice
The same charts and surfaces, checked. The title and every label must be one nonempty line, and values are whole numbers from zero through 10,000,000, which keeps the largest 200-unit scaling product within 32-bit arithmetic. Each chart validates everything before its first surface call, so a bad input draws nothing. A bar is zero long when every value is zero, and an empty trend says so instead of reporting a change.
The caller’s side gains the choice itself: one function picks a surface by name, another a chart, and an unknown name is an error. These are the only places that name concrete types; charts and surfaces never select each other.
// What every surface can draw: one bar, and a figure around the bars.
export interface Surface {
bar(label: string, value: number, largest: number): string;
figure(title: string, bars: readonly string[], note: string): string;
}
// What every chart offers its caller.
export interface Chart {
draw(title: string, points: readonly Point[]): string;
}
// Text nodes only: no attribute ever holds caller text.
const escapeXml = (text: string) =>
text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
// A bar's length on a scale of `width`; zero when every value is zero.
const scaled = (value: number, largest: number, width: number) =>
largest > 0 ? Math.floor((value * width) / largest) : 0;
export class TextSurface implements Surface {
bar(label: string, value: number, largest: number): string {
return `${label} ${'█'.repeat(scaled(value, largest, 20))} ${value}`;
}
figure(title: string, bars: readonly string[], note: string): string {
return [title, ...bars, note].join('\n');
}
}
export class SvgSurface implements Surface {
bar(label: string, value: number, largest: number): string {
const width = scaled(value, largest, 200);
const tip = `${escapeXml(label)}: ${value}`;
return `<rect width="${width}"><title>${tip}</title></rect>`;
}
figure(title: string, bars: readonly string[], note: string): string {
const head = `<title>${escapeXml(title)}</title>`;
return `<svg>${head}${bars.join('')}<text>${escapeXml(note)}</text></svg>`;
}
}
// A title or label is one nonempty line; a value a whole number in range.
const oneLine = (text: string) => text.length > 0 && !/[\r\n]/.test(text);
const validPoint = (p: Point) =>
oneLine(p.label) &&
Number.isInteger(p.value) &&
p.value >= 0 &&
p.value <= MAX_VALUE;
// Every check runs before the first surface call, so a bad input draws nothing.
function validate(title: string, points: readonly Point[], kind: string) {
if (!oneLine(title)) throw new Error('invalid title');
if (!points.every(validPoint)) throw new Error(`invalid ${kind}`);
}
export class TopCategories implements Chart {
private readonly surface: Surface;
constructor(surface: Surface) {
this.surface = surface;
}
draw(title: string, points: readonly Point[]): string {
validate(title, points, 'category');
// sort is stable: categories with equal counts keep their input order.
const top = [...points].sort((a, b) => b.value - a.value).slice(0, 3);
const largest = top[0]?.value ?? 0;
const bars = top.map((p) => this.surface.bar(p.label, p.value, largest));
const note = `${top.length} of ${points.length} shown`;
return this.surface.figure(`Top categories: ${title}`, bars, note);
}
}
export class WeeklyTrend implements Chart {
private readonly surface: Surface;
constructor(surface: Surface) {
this.surface = surface;
}
draw(title: string, points: readonly Point[]): string {
validate(title, points, 'week');
const largest = Math.max(0, ...points.map((p) => p.value));
const bars = points.map((p) => this.surface.bar(p.label, p.value, largest));
const first = points[0];
const last = points[points.length - 1];
const change = first && last ? last.value - first.value : 0;
const sign = change >= 0 ? '+' : '';
const note = first ? `Change: ${sign}${change}` : 'No weeks to show';
return this.surface.figure(`Weekly trend: ${title}`, bars, note);
}
}
// The caller's side: choose a surface, then a chart to draw on it.
export function surfaceFor(kind: string): Surface {
if (kind === 'svg') return new SvgSurface();
if (kind === 'text') return new TextSurface();
throw new Error(`unknown surface: ${kind}`);
}
export function chartFor(kind: string, surface: Surface): Chart {
if (kind === 'top') return new TopCategories(surface);
if (kind === 'trend') return new WeeklyTrend(surface);
throw new Error(`unknown chart: ${kind}`);
} The surfaces are stateless and reusable, and each chart returns one buffered string. A surface that owns a canvas context, a file or a GPU resource introduces lifetimes and failures of its own, and streaming changes the contract: an error after emitting some rows cannot take those bytes back.
03Predict across the boundary
One side does the other’s job.
Experiment
Two charts, two surfaces
// In TopCategories.draw, line 45 becomes:
const bars = top.map((p) =>
this.surface.bar(escapeXml(p.label), p.value, largest)); Reading the TypeScriptStructural interfaces and retained collaborators
Surface describes two methods. Any object with those methods fits it:
TypeScript checks the shape, and implements Surface only asks the compiler
to check it at the class. A chart keeps its surface in a private readonly field
and uses it for each drawing. The interface is a compile-time agreement; it is gone at run
time, which is why production validates the data itself.
[...points] copies before sorting, so the caller’s array keeps its order,
and Array.prototype.sort is stable (required since ES2019), so categories with
equal counts keep their input order. replaceAll is ES2021.
04Meet it in your language
A front end, and the back ends behind it.
Loggers make this split. pino and winston give you one logger API (levels, child loggers with bound fields) and let you choose where entries go: JSON to a file, pretty lines to the console, an HTTP collector. The logger is the abstraction and the transport the implementor, and a new transport works for every logging call already written.
The bug you meet there is the experiment’s, from the other side: work done on the logger’s side that only one transport can use. A message built with a template string reaches every transport as one sentence, so the JSON log cannot be searched by order.
// A logger and its transports, the split pino and winston make: the logger
// decides what an entry says (level, message, fields, a child's bound
// fields); each transport decides how an entry is written.
type Fields = Record<string, string | number>;
interface Transport {
write(level: string, message: string, fields: Fields): void;
}
class JsonTransport implements Transport {
write(level: string, message: string, fields: Fields): void {
console.log(JSON.stringify({ level, msg: message, ...fields }));
}
}
class PrettyTransport implements Transport {
write(level: string, message: string, fields: Fields): void {
const pairs = Object.entries(fields).map(([key, value]) => `${key}=${value}`);
console.log([level.toUpperCase(), message, ...pairs].join(' '));
}
}
class Logger {
private readonly transport: Transport;
private readonly bound: Fields;
constructor(transport: Transport, bound: Fields = {}) {
this.transport = transport;
this.bound = bound;
}
child(fields: Fields): Logger {
return new Logger(this.transport, { ...this.bound, ...fields });
}
info(message: string, fields: Fields = {}): void {
this.transport.write('info', message, { ...this.bound, ...fields });
}
}
const production = process.env.NODE_ENV === 'production';
const transport = production ? new JsonTransport() : new PrettyTransport();
const log = new Logger(transport).child({ service: 'checkout' });
const orderId = 'A-1042';
const attempts = 3;
// Before: the message is written on the logger's side, so every transport
// receives one sentence and the JSON log cannot be searched by orderId.
log.info(`order ${orderId} failed after ${attempts} attempts`);
// After: say what happened and hand over the facts; each transport writes
// them its own way.
log.info('order failed', { orderId, attempts }); Keep the message constant and the facts in fields; each transport writes them its own way.
pino takes the fields first: logger.info({ orderId, attempts }, 'order failed').
05Choose a useful vocabulary
Choose operations that carry enough meaning.
Bridge is useful when chart behaviour and drawing behaviour have separate reasons to change, and a shared operation vocabulary can support their combinations. Four pairings make the separation easy to see here. Counting classes is not the test: functions and tables can express the same choices without a class for every pair.
| Change | Direct functions | Bridge |
|---|---|---|
| One chart, one surface | Keep the compact function. | Adds a contract with no useful second pairing yet. |
| Add another chart | May copy rendering and escaping into each function. | Add chart rules while reusing either surface. |
| Add a surface | May repeat chart selection and ordering. | Add one surface implementation for both charts. |
| A chart needs a drawing no operation offers | Each function draws what it likes. | Widen the contract, and give the new operation a meaning on every surface. |
The rule for which side a decision belongs on: if it should agree across every surface (which rows, their order, what the note says), it belongs to the chart; if it is a rule of one drawing (scale, markup, escaping, width), it belongs to that surface.
Keep the direct drawing function when one small chart is the actual requirement. If several charts already produce the same structured row data, a data model followed by a renderer may be clearer than having chart objects call surface methods. The responsibility split can remain useful even when the handoff changes.
A surface contract that accepts only an arbitrary drawn string can be too weak to preserve meaning. A contract containing every possible visual feature can become difficult to implement. Start with operations demanded by real charts. A share-of-total chart can use the existing operations; a Canvas surface can implement them for both charts. If a future chart needs paired bars, a legend or a second axis, define how those capabilities work on every supported surface before promising that every pairing remains valid.
The interface adds a place to navigate and a contract to maintain. The implementations still need tests for every supported pairing: here, exact output, escaping, validation and input order in every language.
Its neighbours: Strategy also uses composition to replace behaviour; its usual focus is choosing one policy for a context, while Bridge is about two dimensions varying against each other. A constructor taking an interface does not settle the name. Adapter makes an existing interface fit a caller’s expectation, usually after both sides already exist; Bridge is a split you choose up front, before the implementations multiply, and an adapter could connect a third-party drawing library to it. Abstract factory constructs related families whose members must agree; here, chart and surface are intentionally independent choices.
06Test the boundary
Independent change has a boundary.
Before you open the answer below, explain it without the pattern’s name: what can a new chart change while the surface stays selected, what can a new surface change while the chart stays selected, and which requirement would make you revisit the operations between them?
One way to say itTwo reasons for change, and the contract between them
The chart selects and orders meaningful rows and says what they mean. It holds a surface that draws those rows through a shared set of operations, and the surface knows how one kind of drawing works: its scale, its markup, its escaping. The caller can choose either side independently. A new chart, such as share of total, uses the existing operations; a new surface, such as Markdown or Canvas, implements them for every chart, so two charts and three surfaces are five pieces rather than six functions (the “class explosion” Bridge is known for preventing). A requirement neither side can express with the current operations, paired bars, changes the contract, and every surface must then say what the new operation means for it. The failure to watch for is one side doing the other’s job: a chart escaping for SVG, or a surface checking which chart called it. And when there is one chart and one surface, a function is clearer than a bridge.
Practice in the lab
A railway station has the dashboard’s problem: which trains to show and how to show them change for different reasons. Views (the next trains, then each platform) decide which trains appear; displays (an LED board, the announcer, then a phone alert) decide how a train is shown. Each level starts from the previous level’s finished code, so you can begin at any of them.
- BRIDGE-101 (easy) The announcer calls a train that has already left
- BRIDGE-102 (medium) Platform screens and phone alerts, in every pairing Pro
- BRIDGE-103 (hard) Review: platform changes reach some riders, cut short Pro