Testing & Design · T33

Three formats. Same bugs. Open/Closed Principle

Published

One TypeScript report becomes JSON, CSV and Markdown. Store exporter objects in a dictionary, select one and call its shared method. Add a format while keeping the shared caller unchanged.

Explanation & code
The important bit
The registry still changes. Functions are shorter for these tiny rules; classes can hold settings or dependencies. A stable switch is valid. This example assumes a validated non-negative integer bug count; it is not a generic CSV serializer.

Understand it. Then fix it.

One method, different output

Exporter objects accept the same Report and return text. The number of bugs stays unchanged.

type Report = { bugs: number };
interface Exporter {
  format(report: Report): string;
}

Each object owns one format

JSON returns named-value text. CSV returns one header and one numeric row, separated by a CRLF newline.

class JsonExporter implements Exporter {
  format(r: Report) { return JSON.stringify(r); }
}
class CsvExporter implements Exporter {
  format(r: Report) { return `bugs\r\n${r.bugs}`; }
}

Choose the object, then call it

The csv key selects an instance. Its method receives { bugs: 3 } and returns bugs followed by a newline and 3.

const exporters = {
  json: new JsonExporter(),
  csv: new CsvExporter(),
};
type Kind = keyof typeof exporters;
const exportReport = (kind: Kind, r: Report) =>
  exporters[kind].format(r);
exportReport('csv', { bugs: 3 });

Extend at the chosen boundary

Add this implementation and register md: new MarkdownExporter() in the dictionary. The shared exportReport expression stays unchanged. Registration changes; the principle does not freeze every file. For these tiny rules, a function dictionary is shorter.

class MarkdownExporter implements Exporter {
  format(r: Report) { return `**Bugs:** ${r.bugs}`; }
}

Code blocks are teaching excerpts. Keep the surrounding error handling and application requirements.

Save the code excerpts ↓
Read the full transcript

In TypeScript, my bug report now needs JSON, CSV, and Markdown. Must I keep adding branches? Not necessarily. Give each exporter the same method: a report goes in, text comes out. Each object owns one format. JSON stores named values. This exporter turns our report into JSON text: bugs, three. The data stays the same; only its format changes. CSV means comma-separated values, for tables. Our one-column report needs a bugs header, a new line, then three. This class supplies that rule. The dictionary stores exporter objects. The CSV key selects the CSV object. We call its format method with our report and get the two-line text. Now add Markdown, text used to format documents. Write a Markdown exporter and add its object to the dictionary. The existing caller stays unchanged. That is the Open/Closed Principle: open for new behavior, closed to changes in this shared caller. The registry still changes. No rule says every file must be frozen. For tiny formatters like these, functions are shorter. Classes can hold settings or dependencies. A small, stable switch is also fine. Choose for the changes you actually expect. Three formats. Same three bugs. Management calls it innovation.

Go to the source

Next episode ↓Back to all episodes