← Template method lesson
Lab

Template method

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}`);