Definition
A template method defines the skeleton of an operation once and defers some of its steps to subclasses, which may redefine those steps without changing the operation’s structure.
After Gamma et al., Design Patterns (1994), Template Method
01Recognize the situation
The second feed changes the format, not the import rules.
One feed puts the ID before the title; another reverses those columns. At first, two small import functions are a fair design: each is easy to follow, and there is only one caller for each format. Both decode their rows, validate the whole batch, and replace the catalog only when every row passes.
// One import function per feed. (A fragment: Catalog and validate are the
// ones in the program below.)
function importPartner(rows: string[][], catalog: Catalog): string {
const items = rows.map(([id, title]) => ({ id, title }));
const check = validate(items);
if (check.outcome !== 'valid') return check.outcome;
catalog.replace(items);
return 'imported';
}
function importPublisher(rows: string[][], catalog: Catalog): string {
const items = rows.map(([title, id]) => ({ id, title }));
const check = validate(items);
if (check.outcome !== 'valid') return check.outcome;
catalog.replace(items);
return 'imported';
} The pressure arrives when a rule changes. A third feed, a spreadsheet export, has spaces around its cells, so its values must be trimmed before the ID check sees them. Every copy must still decode, normalize, validate every candidate, and make one replacement only after the whole batch passes. If each copy owns that order, the formats can drift: one trims after validating, another saves before the last row is checked.
A template method keeps the shared order in one method and lets each variant supply only the steps that differ. Decoding understands a feed; normalization decides whether to trim; the shared sequence owns validation, stopping and the single replacement.
02Keep the order together
One sequence. Each feed supplies its steps.
The sequence is run on an abstract base class. Each importer must implement decode; the spreadsheet feed also overrides the default normalize. The sequence decodes cells into records, normalizes them, validates the batch, and only then
replaces the catalog; a bad row stops before the catalog changes. This is the whole program the
lab runs: three feeds, one sequence.
type Item = { id: string; title: string };
type Rows = string[][];
type ImportReport = { outcome: string; row: number; trace: string[] };
// Where an import lands: the whole catalog is replaced at once.
class Catalog {
items: Item[] = [{ id: 'existing', title: 'Already here' }];
replace(items: Item[]): void {
this.items = items;
}
}
// The batch rule every feed shares, checked on the normalized rows.
function validate(items: Item[]): { outcome: string; row: number } {
for (let i = 0; i < items.length; i++) {
const { id, title } = items[i];
if (id === '' || id.includes(' ')) {
return { outcome: 'invalid-id', row: i + 1 };
}
if (title === '') return { outcome: 'empty-title', row: i + 1 };
}
return { outcome: 'valid', row: 0 };
}
abstract class ImportTemplate {
// A required step: each feed reads its own columns.
protected abstract decode(rows: Rows): Item[];
// A hook: the default keeps the values as they arrived.
protected normalize(items: Item[]): Item[] {
return items;
}
// The template method: it owns the order and where the run stops.
run(rows: Rows, catalog: Catalog): ImportReport {
const trace = ['decode'];
const decoded = this.decode(rows);
trace.push('normalize');
const candidate = this.normalize(decoded);
trace.push('validate');
const check = validate(candidate);
if (check.outcome !== 'valid') return { ...check, trace };
trace.push('commit');
catalog.replace(candidate);
return { outcome: 'imported', row: 0, trace };
}
}
// The partner feed: ID, then title.
class IdTitleImporter extends ImportTemplate {
protected decode(rows: Rows): Item[] {
return rows.map(([id, title]) => ({ id, title }));
}
}
// The publisher feed: title, then ID.
class TitleIdImporter extends ImportTemplate {
protected decode(rows: Rows): Item[] {
return rows.map(([title, id]) => ({ id, title }));
}
}
// The spreadsheet export: ID first, with spaces around its cells.
class TrimmedImporter extends IdTitleImporter {
protected normalize(items: Item[]): Item[] {
return items.map((item) => ({
id: item.id.trim(),
title: item.title.trim(),
}));
}
}
function show(report: ImportReport): string {
const where = report.row ? ` at row ${report.row}` : '';
return `${report.outcome}${where} (${report.trace.join(' > ')})`;
}
const catalog = new Catalog();
const partner = new IdTitleImporter();
const publisher = new TitleIdImporter();
const spreadsheet = new TrimmedImporter();
const first = partner.run([['intro', 'Introduction'], ['faq', 'FAQ']], catalog);
const second = publisher.run([['', 'terms']], catalog);
const third = spreadsheet.run([[' guide ', ' Guide ']], catalog);
const saved = catalog.items.map((item) => item.id).join(', ');
console.log(`first: ${show(first)}`);
console.log(`second: ${show(second)}`);
console.log(`third: ${show(third)}`);
console.log(`saved: ${saved}`); | Role | Responsibility | What it handles |
|---|---|---|
| Template method: run | Owns the order | Calls decode, then normalize, then validates the batch and commits once, or stops. |
| Required step: decode | Each feed writes it | Turns its own columns into the shared record. |
| Hook: normalize | A default, replaceable | The base class keeps the values; TrimmedImporter overrides it to trim. |
| Caller | Chooses | Picks the importer for a feed and hands it the rows and the catalog. |
03Predict which step runs
A hook is a promise made by name.
Experiment
Three feeds, one sequence
// The shared code renames its hook. Line 31 becomes:
protected clean(items: Item[]): Item[] {
// and line 40 becomes:
const candidate = this.clean(decoded);
// TrimmedImporter, in the spreadsheet team's code, is not touched. What a production importer addsMalformed rows, the full rules, a refused write
The same importer, checked. Rows arrive as lists of cells, so decoding rejects the first row
without exactly two and names it; nothing is normalized. Validation adds an empty batch, the
full ID rule (a lowercase letter, then lowercase letters, digits or hyphens, at most 24) and
duplicate IDs. The duplicate check is why the order matters: trimming can turn "same" and " same " into one ID, so it must see the normalized
values. Trimming removes only spaces and tabs. The catalog may refuse the write, before it
changes anything, and every report carries the trace, the candidates and a copy of what is
saved.
Every overriding method says override. Two layouts and an optional trim already
need four subclasses, and createImporter chooses one; section 5 comes back to
that.
export type Item = { id: string; title: string };
export type Rows = readonly (readonly string[])[];
export type Outcome =
| 'imported'
| 'invalid-row'
| 'empty-batch'
| 'invalid-id'
| 'empty-title'
| 'duplicate-id'
| 'write-failed';
export type Stage = 'decode' | 'normalize' | 'validate' | 'commit';
// row is one-based; 0 means the whole batch.
export type ImportReport = {
outcome: Outcome;
row: number;
trace: Stage[];
candidate: Item[];
saved: Item[];
};
export type Decoded = { items: Item[]; row: number };
const copy = (items: readonly Item[]): Item[] =>
items.map((item) => ({ ...item }));
// The whole catalog is replaced at once, or not at all.
export class Catalog {
private items: Item[];
private fail: boolean;
constructor(initial: readonly Item[], fail = false) {
this.items = copy(initial);
this.fail = fail;
}
// false: the store refused the write, before changing anything.
replace(items: readonly Item[]): boolean {
if (this.fail) return false;
this.items = copy(items);
return true;
}
snapshot(): Item[] {
return copy(this.items);
}
}
// Every row must have exactly two cells; the first bad row stops decoding.
export function decodeColumns(rows: Rows, titleFirst: boolean): Decoded {
const items: Item[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row.length !== 2) return { items: [], row: i + 1 };
const [id, title] = titleFirst ? [row[1], row[0]] : [row[0], row[1]];
items.push({ id, title });
}
return { items, row: 0 };
}
// Trims ASCII spaces and tabs only; other whitespace is data.
export function trimFields(items: readonly Item[]): Item[] {
const trim = (s: string) => s.replace(/^[ \t]+|[ \t]+$/g, '');
return items.map(({ id, title }) => ({ id: trim(id), title: trim(title) }));
}
const validId = /^[a-z][a-z0-9-]{0,23}$/;
// The shared batch rule, on the values the catalog would receive.
type Check = { outcome: Outcome | 'valid'; row: number };
export function validate(items: readonly Item[]): Check {
if (!items.length) return { outcome: 'empty-batch', row: 0 };
const seen = new Set<string>();
for (let i = 0; i < items.length; i++) {
const { id, title } = items[i];
if (!validId.test(id)) return { outcome: 'invalid-id', row: i + 1 };
if (title === '') return { outcome: 'empty-title', row: i + 1 };
if (seen.has(id)) return { outcome: 'duplicate-id', row: i + 1 };
seen.add(id);
}
return { outcome: 'valid', row: 0 };
}
export abstract class ImportTemplate {
protected abstract decode(rows: Rows): Decoded;
protected normalize(items: readonly Item[]): Item[] {
return copy(items);
}
// The template method. TypeScript has no `final`: a subclass can still
// replace run, so keep the subclasses few and reviewed.
run(rows: Rows, catalog: Catalog): ImportReport {
const trace: Stage[] = ['decode'];
const done = (outcome: Outcome, row: number, candidate: Item[]) => ({
outcome,
row,
trace: [...trace],
candidate: copy(candidate),
saved: catalog.snapshot(),
});
const decoded = this.decode(rows);
if (decoded.row) return done('invalid-row', decoded.row, []);
trace.push('normalize');
const candidate = this.normalize(decoded.items);
trace.push('validate');
const check = validate(candidate);
if (check.outcome !== 'valid') {
return done(check.outcome, check.row, candidate);
}
trace.push('commit');
const saved = catalog.replace(candidate);
return done(saved ? 'imported' : 'write-failed', 0, candidate);
}
}
// `override` (TypeScript 4.3) makes the compiler check that the base still
// has the method; noImplicitOverride makes the keyword required.
export class IdTitleImporter extends ImportTemplate {
protected override decode(rows: Rows): Decoded {
return decodeColumns(rows, false);
}
}
export class TitleIdImporter extends ImportTemplate {
protected override decode(rows: Rows): Decoded {
return decodeColumns(rows, true);
}
}
export class TrimmedImporter extends IdTitleImporter {
protected override normalize(items: readonly Item[]): Item[] {
return trimFields(items);
}
}
export class TrimmedTitleIdImporter extends TitleIdImporter {
protected override normalize(items: readonly Item[]): Item[] {
return trimFields(items);
}
}
export type Layout = 'id-title' | 'title-id';
// Two layouts and an optional trim make four subclasses.
export function createImporter(layout: Layout, trim: boolean): ImportTemplate {
if (layout === 'id-title') {
return trim ? new TrimmedImporter() : new IdTitleImporter();
}
if (layout === 'title-id') {
return trim ? new TrimmedTitleIdImporter() : new TitleIdImporter();
}
throw new Error(`unknown layout: ${String(layout)}`);
} Reading the TypeScriptabstract, protected, override and no final
The abstract class cannot be constructed directly. A concrete importer must implement decode (or the compiler reports TS2515); it can inherit or replace the default normalize. The public run method calls those steps through this and owns the remaining sequence. abstract and protected are compile-time constraints, not runtime security: with the types
stripped, a subclass without decode fails only when run reaches
it, with “this.decode is not a function”.
override (TypeScript 4.3) asks the compiler to check that the base class still
has the method: after the rename, override normalize is error TS4113. noImplicitOverride makes the keyword required wherever a method overrides,
so it is already there when a rename comes. A subclass can override run, so
TypeScript does not make the sequence final.
04Meet it in your language
You already supply steps to sequences you do not own.
Node’s stream.Transform is a template method you subclass. The stream decides
when to call your _transform (once per chunk) and your _flush (once,
after the last chunk). A line splitter keeps a partial line until the newline that ends it
arrives; an export’s last line often has none, and without _flush it waits forever
and is dropped without an error.
import { Transform, type TransformCallback } from 'node:stream';
// Node calls _transform once per chunk and _flush once, after the last one.
// The stream owns that order; a subclass supplies the steps.
// Before: a line split across chunks waits in `rest` for its newline. The
// export's last line has none, so it waits forever and is lost.
export class LineSplitterBefore extends Transform {
protected rest = '';
override _transform(
chunk: Buffer,
_encoding: BufferEncoding,
done: TransformCallback
): void {
const parts = (this.rest + chunk.toString()).split('\n');
this.rest = parts.pop() ?? '';
for (const line of parts) this.push(line + '\n');
done();
}
}
// After: the _flush hook hands on whatever is left when the input ends.
export class LineSplitter extends LineSplitterBefore {
override _flush(done: TransformCallback): void {
if (this.rest !== '') this.push(this.rest + '\n');
done();
}
} A hook you do not override still runs: Transform’s default _flush does nothing, and that was the bug. When you subclass someone else’s
sequence, read its list of hooks, not only the methods it forces you to write.
05Decide when it helps
Keep the simple version while it stays honest.
| When | Simple functions | Shared sequence |
|---|---|---|
| One feed, a few rules | Keep a small function: there is nothing to share yet. | A base type and its hooks are an API with one user. |
| Several feeds, one batch contract | Each copy of validate-then-commit can drift when a rule changes. | The order and the stop live once; a new feed writes decode. |
| Steps vary independently (layout and trimming) | Direct functions may stay clearer. | With a subclass per combination, two layouts and a trim make four. |
| One feed needs an extra step (a lookup before validating) | That one function adds the call. | A hook with a default at a fixed point; every hook is API to keep. |
This example varies decoding by layout and uses a default hook for optional trimming. Those
choices are independent: if their combinations keep growing, a sequence that receives both
steps as values is easier to configure than a subclass per combination. That composed form is Strategy applied to each step. The production version already needs four subclasses for two layouts and a trim; a run that took a decode and a normalize function would need none.
A shared sequence controls order only while variants cannot replace its entry point. A TypeScript subclass can replace run; for a rule that must hold, such as never
committing an unvalidated batch, keep the sequence in a function that receives the steps.
The pattern leaves decisions open. Which steps are required and which are hooks with defaults? How many hooks? Every hook is API a variant may come to rely on, and a rename reaches every one of them. The rule for what goes where: put in the sequence whatever must be the same for every variant to be correct (the order, the validation, the stop, the single commit); put in a step what one variant knows that the others don’t (its columns, its whitespace).
Its neighbours: Strategy supplies an interchangeable policy; a template fixes the surrounding sequence and varies designated steps. The book’s Factory Method is often one of a template method’s steps, the one that creates something. Composition over inheritance explores separating independent variation. Chain of responsibility lets handlers pass or claim work; the import sequence calls known stages and stops by their results.
06Make the call
Which part varies, and which order must hold?
Use a template method when variants share an algorithm and its extension points stay small. When a variant needs another order, give that workflow a separate owner.
Before you open the answer below, say it in your own words: what does the sequence own that no variant may change, what does each variant supply, and how would you notice when a variant stops supplying it?
One way to say itWho owns the order, and who owns the steps
The template method owns the order and the rules every variant must obey: here, normalize
before validating, validate the whole batch, stop at the first bad row, replace the catalog
once. Each variant supplies only what it knows: its columns, its whitespace. Required steps
must be written; hooks have defaults and may be replaced, which is also their risk: a hook a
variant thinks it replaces, after a rename or a typo, silently falls back to the default
unless the language checks overrides (Rust always, TypeScript with override,
Go only when the type names every method itself). When a variant needs a different order or
different failure rules, it is a different workflow, not a hook. And when the steps vary
independently, pass them as functions instead of multiplying subclasses.
Practice in the lab
A climbing gym resets its routes every few weeks: close the route, strip the old holds, set new ones, inspect the wall, and reopen only if the inspection passes. Rope routes and boulders do some steps differently, as the lesson’s feeds decode differently, but the order and the rule that a failed inspection keeps a route closed belong to every reset. Each level starts from the previous level’s finished code, so you can begin at any of them.
- TEMPLATE-METHOD-101 (easy) The boulder reset reopens after a failed inspection
- TEMPLATE-METHOD-102 (medium) Competition routes reopen before anyone has climbed them Pro
- TEMPLATE-METHOD-103 (hard) Review: quick refreshes and a board that lists closed routes Pro