An import pipeline reads a file, validates the rows, and writes them to the database. Everything is shared except the reading, which depends on the format.
What people write first
A switch on a format string.
function importFile(kind: string, raw: string) { let reader: Reader; if (kind === 'csv') reader = new CsvReader(raw); else if (kind === 'json') reader = new JsonReader(raw); else if (kind === 'xml') reader = new XmlReader(raw); else throw new Error(`unknown format: ${kind}`);
return validate(reader.rows());}The problem is not this function. It is that the same switch reappears in
preview, in estimateRowCount, in the format-specific error messages — and
adding a fourth format means finding every copy. Miss one and it fails at
runtime for that format only.
The pattern
Put the shared algorithm in a base class, and leave a hole where construction goes.
function parse(kind: string, raw: string) {
if (kind === "csv") return new CsvReader(raw).rows();
if (kind === "json") return new JsonReader(raw).rows();
if (kind === "xml") return new XmlReader(raw).rows();
throw new Error(`unknown format: ${kind}`);
}
// …and the same three-way switch in validate(), in export(),
// in preview() — each one a place to forget the fourth format. Every new format edits a function that already works — and the same switch tends to appear in three other files.
abstract class Importer { /** The hole. Subclasses fill it; nothing else varies. */ protected abstract createReader(raw: string): Reader;
run(raw: string) { const reader = this.createReader(raw); return this.validate(reader.rows()); }}
class CsvImporter extends Importer { protected createReader(raw: string) { return new CsvReader(raw); }}run is written once and works for every format that will ever exist. A new
format is a new subclass, and the compiler will not let you write one that
forgets createReader.
Where the switch went
It did not disappear. Something still has to pick the subclass:
const IMPORTERS = { csv: () => new CsvImporter(), json: () => new JsonImporter(), xml: () => new XmlImporter(),};
const importer = IMPORTERS[kind]?.();if (!importer) throw new Error(`unknown format: ${kind}`);The lighter versions
If the only thing varying is construction, a function is enough:
type CreateReader = (raw: string) => Reader;
function runImport(createReader: CreateReader, raw: string) { return validate(createReader(raw).rows());}
runImport((raw) => new CsvReader(raw), input);That is Strategy again, and it is usually the better choice — no inheritance, no abstract class, and the dependency is visible in the signature.
And when construction genuinely needs a name rather than a new, a static
method does the job with none of the pattern:
class Duration { private constructor(readonly ms: number) {}
static seconds(n: number) { return new Duration(n * 1000); } static minutes(n: number) { return new Duration(n * 60_000); }}Duration.minutes(5) reads better than a constructor with a unit argument, and
the private constructor means there is no second way to build one.
Neighbours
Abstract Factory is this pattern with several related creation methods on one object, so that the products are guaranteed to match each other.
Builder is for a single complex object assembled in steps, not a choice between types.
Prototype sidesteps construction entirely by copying an existing instance.