05 · Design Patterns in TypeScript¶
Classic OOP patterns read differently in TypeScript than in Java or C++ because interfaces are structural and functions are first-class — you often don't need the ceremony the Gang of Four book uses. This module implements five patterns you'll actually run into in real codebases: Singleton, Factory, Strategy, Observer, and Builder.
Singleton¶
class ConfigStore {
private static instance: ConfigStore;
private values = new Map<string, string>();
private constructor() {}
static getInstance(): ConfigStore {
if (!ConfigStore.instance) {
ConfigStore.instance = new ConfigStore();
}
return ConfigStore.instance;
}
set(key: string, value: string): void {
this.values.set(key, value);
}
get(key: string): string | undefined {
return this.values.get(key);
}
}
ConfigStore.getInstance().set("env", "production");
console.log(ConfigStore.getInstance().get("env"));
console.log(ConfigStore.getInstance() === ConfigStore.getInstance());
The private constructor() is what makes this a real singleton at the
type level — new ConfigStore() from outside the class is a compile
error, so getInstance() is the only way in.
Factory¶
interface Shape {
area(): number;
}
class Circle implements Shape {
constructor(private radius: number) {}
area(): number {
return Math.PI * this.radius ** 2;
}
}
class Square implements Shape {
constructor(private side: number) {}
area(): number {
return this.side ** 2;
}
}
type ShapeKind = "circle" | "square";
function createShape(kind: ShapeKind, size: number): Shape {
switch (kind) {
case "circle":
return new Circle(size);
case "square":
return new Square(size);
}
}
console.log(createShape("circle", 2).area().toFixed(2));
console.log(createShape("square", 2).area());
Because kind is a union of string literals, switch over it is
exhaustive — add a third ShapeKind and TypeScript will flag
createShape as possibly not returning a value on every path (with
noImplicitReturns), catching the missed case at compile time.
Strategy¶
interface DiscountStrategy {
apply(total: number): number;
}
const noDiscount: DiscountStrategy = { apply: (t) => t };
const tenPercentOff: DiscountStrategy = { apply: (t) => t * 0.9 };
class Cart {
constructor(private strategy: DiscountStrategy) {}
checkout(total: number): number {
return this.strategy.apply(total);
}
}
console.log(new Cart(noDiscount).checkout(100));
console.log(new Cart(tenPercentOff).checkout(100));
No abstract base class needed — DiscountStrategy is structural, so
any object shaped like { apply(total: number): number } qualifies,
including a plain object literal as shown here.
Observer¶
interface Observer<T> {
notify(value: T): void;
}
class Subject<T> {
private observers: Observer<T>[] = [];
subscribe(o: Observer<T>): void {
this.observers.push(o);
}
publish(value: T): void {
for (const o of this.observers) o.notify(value);
}
}
const priceFeed = new Subject<number>();
priceFeed.subscribe({ notify: (v) => console.log("logger sees", v) });
priceFeed.subscribe({ notify: (v) => console.log("alerter sees", v) });
priceFeed.publish(42);
Subject<T> being generic means the same class works for a number feed,
a string feed, or a feed of custom event objects — Observer<T> and
Subject<T> stay linked so a Subject<number> rejects an observer
expecting string.
Builder¶
class RequestBuilder {
private method = "GET";
private headers: Record<string, string> = {};
private url = "";
setMethod(method: string): this {
this.method = method;
return this;
}
setUrl(url: string): this {
this.url = url;
return this;
}
addHeader(key: string, value: string): this {
this.headers[key] = value;
return this;
}
build() {
return { method: this.method, url: this.url, headers: this.headers };
}
}
const req = new RequestBuilder()
.setMethod("POST")
.setUrl("/users")
.addHeader("Content-Type", "application/json")
.build();
console.log(req);
The this return type (not RequestBuilder) matters if a subclass adds
more chained methods — with this, the subclass's own chain methods
keep returning the subclass type, so a subclassed builder doesn't lose
its extra methods midway through a chain.
Traps¶
A Factory switch without noImplicitReturns won't catch a missing
case — TypeScript infers the return type includes undefined on the
unhandled branch only if you've turned that flag on; otherwise a typo'd
or added ShapeKind value silently returns undefined at runtime with
no compile error.
Singleton state leaks across tests. Because ConfigStore.instance
is a static, every test in the same process shares it — a common
source of "works alone, fails in the suite" bugs. Add a
static resetForTests() escape hatch if you use this pattern in code
under test.
this return types don't survive being reassigned to a
RequestBuilder-typed variable. If you write
const b: RequestBuilder = new RequestBuilder(); b.setMethod("POST"),
the chain still works, but any subclass-specific method called after
that point on b is invisible — the variable's declared type wins.
How It Actually Works¶
Classic patterns like Factory and Builder lean on structural typing in a way that's specific to TypeScript versus nominally-typed languages: a Factory function typed to return an interface (function createLogger(): Logger) lets you swap the concrete class returned without any caller-visible change, because callers only ever check the returned value against Logger's shape, never against which class actually constructed it — polymorphism here is enforced entirely by structural compatibility, not by an explicit implements relationship (though implements does add a compile-time check that the class's shape genuinely satisfies the interface at declaration time, catching a missing method immediately rather than only at the return-type check).
The Strategy pattern (swapping an algorithm behind a shared function-type interface) is where function-type contravariance genuinely matters for correctness: if your Strategy type is (input: Base) => Result and you're tempted to write a concrete strategy as (input: Derived) => Result (accepting only a narrower subtype), that assignment is rejected by the checker under strictFunctionTypes, because a narrower parameter type could receive a Base at the call site that isn't actually a Derived, and the checker's contravariant parameter check exists precisely to catch that unsound substitution — this is a real category of pattern-implementation bug that structural + variance checking specifically prevents.
The Singleton pattern intersects with module resolution rather than the type system proper: TypeScript modules are only ever evaluated once per resolved module specifier (mirroring how Node's require cache and ES module instantiation both work), so a module-level const instance = new Service() genuinely is a runtime singleton — but only within one module graph; if your build produces multiple bundles or your module resolution config causes the same file to resolve to two different specifiers, you silently get two "singleton" instances, a runtime consequence of the module system the type checker has no visibility into or ability to prevent.
Cheat sheet¶
| Pattern | TypeScript feature that makes it clean |
|---|---|
| Singleton | private constructor + private static instance |
| Factory | Discriminated string-literal union + exhaustive switch |
| Strategy | Structural interfaces — no base class required |
| Observer | Generic Subject<T> / Observer<T> pair |
| Builder | this-typed chained methods |
Exercise¶
Implement the Decorator pattern (the GoF structural pattern, not
TypeScript's @decorator syntax) for the Shape interface: write a
BorderedShape class that wraps another Shape, implements Shape
itself, and returns wrapped.area() + borderPadding. Compose two
levels deep (new BorderedShape(new BorderedShape(new Circle(2), 1), 1))
and log the result to confirm both paddings apply.