Typing the Template Method Pattern in TypeScript — Practical Guide
Introduction
The Template Method pattern is a behavioral design pattern that defines the skeleton of an algorithm in an operation, deferring some steps to subclasses. In TypeScript, implementing this pattern while preserving strong typing, flexibility, and runtime safety can be challenging for intermediate developers who want both expressive code and compile-time guarantees.
In this tutorial you'll learn how to model Template Method implementations in TypeScript, including typed abstract templates, typed hooks, optional steps, runtime guards, and ways to compose template methods with other patterns. We'll cover multiple designs—from class-based abstract templates to function-based templates using higher-order functions—explain trade-offs, and provide concrete code you can drop into your projects.
By the end you'll have a toolbox for writing Template Method implementations that are easy to read, well-typed, and maintainable. We'll also link to related articles that deepen specific techniques, like using assertion functions and higher-order function typing for advanced composition.
What you'll get:
- A clear understanding of typing strategies for templates
- Practical class- and function-based examples with type-safe hooks
- Runtime guard patterns and assertions for safer execution
- Composition strategies with builders, factories, and modules
This guide is aimed at intermediate TypeScript developers who already understand basic generics, union types, and classes. If that sounds like you, let's get started.
Background & Context
The Template Method pattern originates from object-oriented design: define the algorithm's skeleton and let subclasses provide concrete behavior for certain steps. In TypeScript, you can model this exactly with abstract classes, but plain abstract classes may not express all the safety you want: optional steps, typed return values for hooks, and guaranteed postconditions.
TypeScript's type system provides tools—generics, discriminated unions, type predicates, and assertion functions—that we can combine to create safer Template Method variants. We'll explore class-based templates, function-based templates using higher-order functions, and hybrid approaches that combine static types with runtime checks.
Using typed templates improves developer UX by surfacing required hooks, preventing invalid combinations at compile-time, and making refactors safer. We'll also show how to combine templates with other patterns like the Strategy pattern for swappable steps, and Builder/Factory patterns for constructing complex templates.
For more on composing behavior contracts and alternatives to template implementations, see our guide on the Typing Strategy Pattern Implementations in TypeScript.
Key Takeaways
- The Template Method pattern defines an algorithm skeleton and delegates steps to hook implementations.
- TypeScript allows multiple implementations: abstract classes, interfaces with HOFs, and composition-based approaches.
- Use generics to type hook inputs and outputs, and use discriminated unions or assertion functions for runtime safety.
- Prefer explicit hook types over loosely typed callbacks; leverage higher-order functions for modular templates.
- Combine Template Method with Builder, Factory, or Strategy patterns for flexible construction and substitution.
Prerequisites & Setup
Before you begin, ensure you have:
- TypeScript 4.x installed (higher versions are fine). Install via npm:
npm i -D typescript. - A basic project with tsconfig.json. Use
tsc --initif needed. - Familiarity with classes, generics, and type guards. If you want deeper reading on writing advanced HOF types, check the guide on Typing Higher-Order Functions in TypeScript — Advanced Scenarios.
Create a sample folder structure for examples and run tsc --noEmit for type checks while you write code. We'll include runnable snippets you can paste into .ts files.
Main Tutorial Sections
1) Basic Class-Based Template (typed abstract class)
Start with the classic abstract-class approach: define the template method in a base class and abstract methods for steps.
abstract class DataExportTemplate {
// Template method
export(): string {
const header = this.buildHeader();
const body = this.buildBody();
const footer = this.buildFooter();
return [header, body, footer].filter(Boolean).join('
');
}
protected abstract buildHeader(): string | null;
protected abstract buildBody(): string;
protected protected buildFooter?(): string | null; // optional step (TS syntax note below)
}TypeScript doesn't allow protected + ? on methods in the same way as properties. A better pattern is to declare optional steps as methods returning nullable values or by providing defaults in the base class:
abstract class DataExportTemplateV2 {
export(): string {
const header = this.buildHeader();
const body = this.buildBody();
const footer = this.buildFooter();
return [header, body, footer].filter(Boolean).join('
');
}
protected buildHeader(): string | null { return null; } // optional default
protected abstract buildBody(): string;
protected buildFooter(): string | null { return null; } // optional default
}This pattern ensures implementers must provide buildBody, while header/footer are optional. Use generics when the algorithm needs typed input or output.
2) Typing inputs and outputs with generics
Often templates operate on data. Generics make the base algorithm generic and enforce consistent types across hooks:
abstract class Processor<TInput, TOutput> {
process(input: TInput): TOutput {
const normalized = this.normalize(input);
const transformed = this.transform(normalized);
return this.finalize(transformed);
}
protected abstract normalize(input: TInput): TInput;
protected abstract transform(input: TInput): TOutput;
protected finalize(result: TOutput): TOutput { return result; }
}Now any subclass must respect the TInput and TOutput types. This prevents accidentally returning wrong types from hooks and enables IDEs to provide better autocomplete.
3) Function-based Template using higher-order functions
If you prefer composition over inheritance, use higher-order functions that accept step implementations. This is more functional and often easier to test.
type Normalize<I> = (input: I) => I;
type Transform<I, O> = (input: I) => O;
default function createProcessor<I, O>(
normalize: Normalize<I>,
transform: Transform<I, O>,
finalize?: (out: O) => O
) {
return (input: I) => {
const n = normalize(input);
const t = transform(n);
return (finalize ?? ((x) => x))(t);
};
}This style benefits from the advanced typed HOF techniques discussed in Typing Higher-Order Functions in TypeScript — Advanced Scenarios. The function-based approach works well when you want to easily swap out and compose steps.
4) Optional and Hook Methods: typing strategies
A common requirement is allowing optional hooks (e.g., preValidate, postProcess). Use defaults in the base class or optional parameters in factory functions.
Class pattern with defaults:
abstract class TemplateWithHooks<I, O> {
run(input: I): O {
this.preHook?.(input);
const out = this.execute(input);
this.postHook?.(out);
return out;
}
protected preHook?(input: I): void;
protected abstract execute(input: I): O;
protected postHook?(output: O): void;
}For HOFs, use optional parameters with typed defaults. If you need to detect whether a hook exists at runtime, consider type predicates or assertion functions covered in Using Assertion Functions in TypeScript (TS 3.7+).
5) Runtime Validation and Assertions for Template Contracts
Compile-time types are powerful, but runtime checks are often necessary for resilience. Assertion functions allow you to validate preconditions and narrow types during execution.
function assertIsNonEmptyArray<T>(v: T[] | null | undefined, msg = 'Expected non-empty array'): asserts v is T[] {
if (!Array.isArray(v) || v.length === 0) throw new Error(msg);
}
class ListProcessor extends TemplateWithHooks<any[], number> {
protected execute(input: any[]): number {
assertIsNonEmptyArray(input);
return input.length;
}
}Learn more about writing assertion functions and how they interact with TypeScript's control flow in Using Assertion Functions in TypeScript (TS 3.7+).
6) Type Predicates for selective hook implementations
Type predicates are useful when a template accepts different kinds of inputs (union types) and must dispatch to different hooks:
type Shape = Circle | Rectangle;
function isCircle(s: Shape): s is Circle { return s.kind === 'circle'; }
class ShapeRenderer extends Processor<Shape, string> {
protected normalize(s: Shape) { return s; }
protected transform(s: Shape) {
if (isCircle(s)) return this.renderCircle(s);
return this.renderRectangle(s);
}
private renderCircle(c: Circle) { return `Circle r=${c.r}`; }
private renderRectangle(r: Rectangle) { return `Rect ${r.w}x${r.h}`; }
}If you're filtering or branching arrays of typed items inside templates, check Using Type Predicates for Filtering Arrays in TypeScript for patterns and examples.
7) Composing Template Methods with Strategy & Adapter
Template Method delegates steps; Strategy lets you swap implementations at runtime. Compose them by typing the step as an interface and accepting an implementation in the constructor.
interface TransformStrategy<I, O> { transform(input: I): O }
class StrategyProcessor<I, O> extends Processor<I, O> {
constructor(private strategy: TransformStrategy<I, O>) { super(); }
protected normalize(input: I) { return input; }
protected transform(input: I) { return this.strategy.transform(input); }
}This composition pattern mirrors the ideas in the Typing Strategy Pattern Implementations in TypeScript guide and helps keep steps decoupled and reusable.
8) Constructing and Configuring Templates with Builder or Factory
For templates with many optional parameters and steps, Builders or Factories improve ergonomics and safety.
Factory example:
function createCsvExporter(options: { delimiter?: string } = {}) {
const delimiter = options.delimiter ?? ',';
return new (class CsvExporter extends DataExportTemplateV2 {
protected buildHeader() { return 'id' + delimiter + 'name'; }
protected buildBody() { return '1' + delimiter + 'Alice'; }
})();
}For larger configuration surfaces, a typed builder enforces required steps during construction. See the practical patterns in Typing Factory Pattern Implementations in TypeScript and Typing Builder Pattern Implementations in TypeScript for concrete builder/factory patterns that integrate with templates.
9) Caching and Memoization inside Template Steps
Expensive steps inside a template often benefit from caching. Use typed memoization helpers so caches preserve types for inputs and outputs.
function memoize<I, O>(fn: (i: I) => O) {
const cache = new Map<I, O>();
return (i: I) => {
if (cache.has(i)) return cache.get(i)!;
const r = fn(i);
cache.set(i, r);
return r;
};
}
class StatsProcessor extends Processor<number[], number> {
private expensive = memoize((arr: number[]) => arr.reduce((a,b)=>a+b,0));
protected normalize(input: number[]) { return input; }
protected transform(input: number[]) { return this.expensive(input); }
}For production-ready memoization patterns and key strategies, read our guide on Typing Memoization Functions in TypeScript.
10) Revealing Module & Encapsulation for Template Families
If you export a family of templates from a module, consider the Revealing Module pattern to control constructed APIs and hide internals. This reduces surface area and improves maintainability.
export function createExporters() {
class JsonExporter extends DataExportTemplateV2 { /* ... */ }
class CsvExporter extends DataExportTemplateV2 { /* ... */ }
return { JsonExporter, CsvExporter };
}Learn more about encapsulation and module-level patterns in Typing Revealing Module Pattern Implementations in TypeScript.
Advanced Techniques
After mastering basics, apply these advanced strategies:
- Variadic and conditional generics to build templates whose hooks change result types depending on configuration. This gives a single template signature that adapts to options at compile time.
- Use branded types for opaque internal values to prevent accidental mixing of domain types across steps.
- Use assertion functions to enforce invariants at step boundaries and to provide narrowed types to subsequent hooks. Reference Using Assertion Functions in TypeScript (TS 3.7+).
- Replace inheritance with composition where appropriate: prefer HOF templates for better testability and less brittle subclasses.
- When performance matters, avoid per-run allocations in the template; pre-compile or memoize step implementations using patterns in the memoization guide.
These techniques increase type-safety and runtime performance but add complexity—use them where the payoff is clear.
Best Practices & Common Pitfalls
Best Practices:
- Favor explicit hook typings: declare input/output types for each hook so interfaces document expectations.
- Prefer default implementations for optional steps over optional method syntax; defaults reduce subclass boilerplate.
- Use composition/HOFs if you need loose coupling and easier unit testing.
- Validate critical invariants with assertion functions; trust TypeScript for compile-time checks, but don't skip runtime checks when inputs are untrusted.
Common Pitfalls:
- Overusing abstract classes leading to deep inheritance chains—consider Strategy or HOFs instead.
- Loose any types for hooks: this defeats the purpose of TypeScript. Use generics and narrow types with predicates.
- Relying only on runtime behavior without typing—document hook contracts in types so consumers get IDE help.
Troubleshooting tips:
- If a subclass's type signature doesn't match the base, TypeScript will error. Use generics to align types across hierarchy.
- If optional hooks are undefined at runtime, ensure you provide defaults or guard calls with
?.operator.
Real-World Applications
Template Method is ideal when you have a fixed algorithm with variable steps. Common use cases:
- File exporters/importers with consistent pre-processing, content generation, and post-processing steps.
- Processing pipelines where steps like validation, normalization, transform, and emit are standard but implementations vary per data type.
- UI rendering engines that define render flows but allow subclasses or plugins to customize parts.
Combine templates with Builder/Factory patterns to construct complex templates (see Typing Builder Pattern Implementations in TypeScript and Typing Factory Pattern Implementations in TypeScript) or with Strategy to allow runtime step-swapping as in the Typing Strategy Pattern Implementations in TypeScript article.
Conclusion & Next Steps
The Template Method pattern remains a practical tool in TypeScript when you need a controlled algorithm skeleton and typed extension points. Choose the right implementation style—class-based for OOP clarity or function-based for composability. Use generics, assertion functions, and type predicates to keep contracts explicit and safe.
Next steps: try converting an existing exported/import pipeline to a typed template and add assertion-based guards. Explore composition with strategies and builders to see which approach fits your codebase.
If you're interested in advanced composition and performance, continue with the related guides on assertion functions and memoization referenced above.
Enhanced FAQ
Q1: When should I prefer inheritance-based template methods over function-based templates?
A1: Use inheritance (abstract classes) when the pattern naturally maps to an "is-a" relationship and when you expect subclasses to extend both state and behavior. Inheritance gives a clear place for defaults and protected helpers. Choose function-based templates when you prefer composition, easier unit testing, and the ability to pass closures with encapsulated state. HOF templates are often more flexible and reduce brittle inheritance hierarchies.
Q2: How do I type optional hooks without losing safety?
A2: Provide default implementations in the base class that return safe defaults (e.g., null, undefined, or identity functions). In HOFs, accept optional parameters and fill them with typed defaults. When a step is optional but used later, guard the call (e.g., this.preHook?.(arg)) or assert its presence beforehand with an assertion function.
Q3: Can Template Method be combined with Strategy? How do I type that?
A3: Absolutely. Type step interfaces as separate types (e.g., TransformStrategy<I, O>) and accept them as constructor dependencies or function parameters. TypeScript generics ensure the strategy's transform method signature matches the template's expectations. This decouples step selection from template logic and enables runtime swappability.
Q4: How can I ensure templates remain performant, especially with heavy hooks?
A4: Precompute constant data outside the run method, memoize expensive per-argument computations (typed memoize helpers), and avoid per-run allocations in hot paths. If a hook captures state, ensure it recreates heavy resources lazily. Use typed memoization patterns from Typing Memoization Functions in TypeScript to cache results safely.
Q5: What about runtime type safety when external input is untrusted?
A5: Use assertion functions and type predicates to validate inputs at entry points. Assertion functions narrow types for subsequent code and throw early on invalid data. See Using Assertion Functions in TypeScript (TS 3.7+) for practical examples of writing these guards.
Q6: How do I handle branching logic inside templates for union inputs?
A6: Use discriminated unions and type predicates. Define a kind property or similar discriminator and write small predicate functions (e.g., isCircle) to narrow the union. This simplifies hooks and keeps each branch well-typed. We covered predicate usage in the section on selective hook implementations and you can refer to Using Type Predicates for Filtering Arrays in TypeScript for related patterns.
Q7: Are there risks with deep inheritance hierarchies for templates?
A7: Yes—deep hierarchies can become difficult to reason about, lead to fragile code when base implementations change, and make testing harder. Prefer composition (Strategy/HOF) when you need flexibility. If you must use inheritance, document protected methods and keep inheritance shallow.
Q8: How should templates be packaged and exported in libraries?
A8: Use the Revealing Module or explicit exports to control your public API—export factories and constructors you want consumers to use, and keep helper classes internal. The Typing Revealing Module Pattern Implementations in TypeScript guide has actionable patterns for packaging template families and hiding implementation details.
Q9: Can I enforce required steps at compile time when using builders?
A9: Yes. Typed builders can enforce required configuration via generics or conditional types that change the builder's type as required fields are set. Implement the builder so that build() is only available once all required steps are configured—this provides stronger guarantees than runtime checks. See Typing Builder Pattern Implementations in TypeScript for detailed examples.
Q10: How do I test template implementations effectively?
A10: Unit-test individual hooks by making them public or exposing them via factory functions. For end-to-end behavior, test the run or export method with mock strategies or step implementations. For HOF templates, pass small test doubles. Use snapshot tests for output-heavy exporters, and profile hot paths for performance-sensitive templates.
If you'd like, I can provide a small sample repository structure and ready-to-run TypeScript files for the class-based and function-based implementations covered here. I can also produce a checklist for migrating an existing codebase to typed templates.
