Module 18: Advanced Patterns
Teach: How TypeScript's advanced type features and design patterns solve real architectural problems -- decorators for cross-cutting concerns, builder and singleton patterns for object creation, and mapped/conditional/template literal types for type-level programming. See: TC39 decorators logging method calls, a fluent builder API, a singleton database connection, and types that transform other types automatically. Feel: Empowered to reach for these tools when simpler approaches are not expressive enough.
Where this fits: You have mastered TypeScript's core type system -- generics, utility types, type narrowing. This module goes further, into patterns that let the type system model complex relationships and that let you write code that is both more expressive and more type-safe than what basic annotations can achieve.
TC39 Stage 3 Decorators
Teach: How TC39 Stage 3 decorators add cross-cutting behavior like logging and timing to class methods without modifying the original code. See: @log and @timed decorators wrapping a MathService class, including stacking multiple decorators on one method. Feel: Excited that decorators solve real boilerplate problems elegantly.
Method Decorators
Decorators are a way to add behavior to classes and their members without modifying the original code. TypeScript 5 supports the TC39 Stage 3 decorator standard, which does not require the experimentalDecorators flag. A method decorator is a function that wraps a class method, adding logic before or after it runs. The two most common uses are logging (recording when methods are called and what they return) and timing (measuring how long methods take). Decorators solve a real problem: cross-cutting concerns that would otherwise require you to copy the same boilerplate into every method.
Set up the project:
mkdir ~/advanced-ts
cd ~/advanced-ts
npm init -y
npm install --save-dev typescript @types/node
Create tsconfig.json with decorator support:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"strict": true,
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true,
"experimentalDecorators": false
},
"include": ["src/**/*"]
}
Note: TypeScript 5 decorators use the TC39 Stage 3 standard and do not need experimentalDecorators.
mkdir src
Create src/decorators.ts:
// src/decorators.ts
// --- Method decorator: logging ---
function log(
_target: any,
context: ClassMethodDecoratorContext
) {
const methodName = String(context.name);
return function (this: any, ...args: any[]) {
console.log(`[LOG] ${methodName}(${args.map(a => JSON.stringify(a)).join(", ")})`);
const result = (_target as Function).apply(this, args);
console.log(`[LOG] ${methodName} returned ${JSON.stringify(result)}`);
return result;
};
}
// --- Method decorator: timing ---
function timed(
_target: any,
context: ClassMethodDecoratorContext
) {
const methodName = String(context.name);
return function (this: any, ...args: any[]) {
const start = performance.now();
const result = (_target as Function).apply(this, args);
const elapsed = (performance.now() - start).toFixed(3);
console.log(`[TIMER] ${methodName} took ${elapsed}ms`);
return result;
};
}
class MathService {
@log
add(a: number, b: number): number {
return a + b;
}
@timed
fibonacci(n: number): number {
if (n <= 1) return n;
return this.fibonacci(n - 1) + this.fibonacci(n - 2);
}
@log
@timed
multiply(a: number, b: number): number {
return a * b;
}
}
const math = new MathService();
math.add(3, 4);
console.log("---");
math.multiply(5, 6);
Compile and run:
npx tsc
node dist/decorators.js
Notice how multiply has both @log and @timed stacked -- decorators compose naturally.
Builder Pattern
Teach: How the Builder pattern constructs complex objects step by step with a fluent, chainable API. See: A RequestBuilder class with method chaining that builds validated HttpRequest objects, including error handling for missing required fields. Feel: Appreciation for how builders make complex construction readable and type-safe.
Fluent API for Complex Object Construction
The Builder pattern solves the problem of constructing complex objects step by step. Instead of a constructor with ten parameters, you chain method calls that each configure one aspect of the object. Each setter method returns this, which enables the fluent chaining syntax. The build method at the end validates that all required properties are set and returns the final object. This pattern is especially powerful in TypeScript because the type system can enforce that required properties exist at compile time.
Create src/builder.ts:
// src/builder.ts
interface HttpRequest {
method: "GET" | "POST" | "PUT" | "DELETE";
url: string;
headers: Record<string, string>;
body?: string;
timeout: number;
}
class RequestBuilder {
private request: Partial<HttpRequest> = {};
setMethod(method: HttpRequest["method"]): this {
this.request.method = method;
return this;
}
setUrl(url: string): this {
this.request.url = url;
return this;
}
addHeader(key: string, value: string): this {
if (!this.request.headers) {
this.request.headers = {};
}
this.request.headers[key] = value;
return this;
}
setBody(body: object): this {
this.request.body = JSON.stringify(body);
this.addHeader("Content-Type", "application/json");
return this;
}
setTimeout(ms: number): this {
this.request.timeout = ms;
return this;
}
build(): HttpRequest {
if (!this.request.method) throw new Error("Method is required");
if (!this.request.url) throw new Error("URL is required");
return {
method: this.request.method,
url: this.request.url,
headers: this.request.headers ?? {},
body: this.request.body,
timeout: this.request.timeout ?? 5000,
};
}
}
// Usage — fluent, readable API
const getRequest = new RequestBuilder()
.setMethod("GET")
.setUrl("https://api.example.com/users")
.addHeader("Authorization", "Bearer token123")
.setTimeout(3000)
.build();
const postRequest = new RequestBuilder()
.setMethod("POST")
.setUrl("https://api.example.com/users")
.setBody({ name: "Alice", email: "alice@example.com" })
.addHeader("Authorization", "Bearer token123")
.build();
console.log("GET Request:", JSON.stringify(getRequest, null, 2));
console.log("\nPOST Request:", JSON.stringify(postRequest, null, 2));
// Builder pattern error handling
try {
new RequestBuilder().setUrl("/test").build(); // Missing method
} catch (err) {
console.log(`\nError caught: ${(err as Error).message}`);
}
Singleton Pattern
Teach: How the Singleton pattern ensures exactly one instance of a class exists, and when this is the right choice. See: A Database class with a private constructor and static getInstance, plus a generic Registry for managing named service instances. Feel: Clear on when singletons are appropriate and how to implement them safely.
One Instance, Shared Everywhere
The Singleton pattern ensures a class has only one instance and provides a global point of access to it. This is the right pattern for resources that should not be duplicated, like database connections, configuration managers, or logging services. The private constructor prevents direct instantiation -- the only way to get an instance is through the static getInstance method, which creates the instance on first call and returns the existing instance on every subsequent call. The generic Registry class shows a related pattern: a single registry that manages named instances of any type.
Create src/singleton.ts:
// src/singleton.ts
class Database {
private static instance: Database | null = null;
private connectionCount = 0;
private constructor(private host: string, private port: number) {
console.log(`[DB] Connecting to ${host}:${port}...`);
}
static getInstance(host = "localhost", port = 5432): Database {
if (!Database.instance) {
Database.instance = new Database(host, port);
}
return Database.instance;
}
query(sql: string): string {
this.connectionCount++;
return `[DB] Query #${this.connectionCount}: ${sql} → (results)`;
}
getInfo(): string {
return `Connected to ${this.host}:${this.port}, queries executed: ${this.connectionCount}`;
}
// For testing: reset the singleton
static resetInstance(): void {
Database.instance = null;
}
}
// Both variables point to the SAME instance
const db1 = Database.getInstance("db.example.com", 5432);
const db2 = Database.getInstance(); // Returns existing instance
console.log(db1 === db2); // true — same object
console.log(db1.query("SELECT * FROM users"));
console.log(db2.query("INSERT INTO logs VALUES ('test')"));
console.log(db1.getInfo()); // Shows 2 queries — both used the same instance
// Application-level singleton with generics
class Registry<T> {
private items = new Map<string, T>();
register(key: string, item: T): void {
if (this.items.has(key)) {
throw new Error(`Key "${key}" already registered`);
}
this.items.set(key, item);
}
get(key: string): T {
const item = this.items.get(key);
if (!item) throw new Error(`Key "${key}" not found`);
return item;
}
list(): string[] {
return [...this.items.keys()];
}
}
interface Service {
name: string;
execute(): string;
}
const registry = new Registry<Service>();
registry.register("logger", {
name: "LoggerService",
execute: () => "Logging...",
});
registry.register("auth", {
name: "AuthService",
execute: () => "Authenticating...",
});
console.log(`\nRegistered services: ${registry.list().join(", ")}`);
console.log(registry.get("logger").execute());
console.log(registry.get("auth").execute());
Mapped Types
Teach: How mapped types transform every property of an existing type to create new types automatically. See: Immutable<T>, Nullable<T>, and StringProps<T> applied to a User interface, producing readonly, nullable, and filtered variants. Feel: A sense of power from writing type-level transformations that keep derived types in sync with the source.
Transforming Types Automatically
Mapped types let you create new types by transforming every property of an existing type. Think of them as a for loop over an object's keys, but at the type level. Immutable takes any type and makes all its properties readonly. Nullable takes any type and makes every property accept null. StringProps uses a conditional filter to keep only the properties whose values are strings. These are not utility functions that transform data at runtime -- they are utility types that transform type shapes at compile time. This is type-level programming.
Create src/advanced_types.ts:
// src/advanced_types.ts
// --- Mapped Types ---
// Create new types by transforming existing ones
interface User {
id: number;
name: string;
email: string;
isAdmin: boolean;
}
// Make all properties readonly
type Immutable<T> = { readonly [K in keyof T]: T[K] };
// Make all properties nullable
type Nullable<T> = { [K in keyof T]: T[K] | null };
// Pick only string properties
type StringProps<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
type ImmutableUser = Immutable<User>;
type NullableUser = Nullable<User>;
type UserStrings = StringProps<User>; // { name: string; email: string }
const frozenUser: ImmutableUser = { id: 1, name: "Alice", email: "a@b.com", isAdmin: false };
// frozenUser.name = "Bob"; // Error: readonly
const nullableUser: NullableUser = { id: null, name: "Alice", email: null, isAdmin: null };
const userStrings: UserStrings = { name: "Alice", email: "alice@example.com" };
console.log("Immutable:", frozenUser);
console.log("Nullable:", nullableUser);
console.log("String props:", userStrings);
Conditional Types
Teach: How conditional types use extends and infer to branch and extract types at the type level. See: IsString<T>, ElementType<T> extracting array element types, and Unwrap<T> extracting Promise resolved types. Feel: Understanding that conditional types are the if/else of the type system and power many built-in utility types.
Types That Depend on a Condition
Conditional types use a syntax that looks like the ternary operator, but at the type level. If type T extends some constraint, use one type; otherwise, use another. The infer keyword inside a conditional type lets you extract a type from inside another type. ElementType extracts the element type from an array. Unwrap extracts the resolved type from a Promise. These are the same mechanisms that power built-in utility types like ReturnType and Parameters.
Continuing in src/advanced_types.ts:
// --- Conditional Types ---
// Types that depend on a condition
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<string>; // "yes"
type B = IsString<number>; // "no"
// Extract array element type
type ElementType<T> = T extends (infer E)[] ? E : never;
type NumElement = ElementType<number[]>; // number
type StrElement = ElementType<string[]>; // string
// Extract promise value type
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type P1 = Unwrap<Promise<string>>; // string
type P2 = Unwrap<number>; // number
// Demonstrate at runtime
function checkTypes(): void {
const a: A = "yes";
const b: B = "no";
console.log(`\nIsString<string> = ${a}`);
console.log(`IsString<number> = ${b}`);
}
checkTypes();
Template Literal Types
Teach: How template literal types combine union types to generate every possible string combination at the type level. See: ColorVariant, Endpoint, and MouseEvent types generated from unions, plus a HandlerMap that auto-generates event handler interfaces. Feel: Amazed that TypeScript can generate dozens of precise string types from a few lines of type-level code.
Building String Types from Combinations
Template literal types use the same backtick syntax as JavaScript template strings, but they operate at the type level. When you combine two union types inside a template literal type, TypeScript generates every possible combination. A Shade union with two values crossed with a Color union with three values produces six ColorVariant values. Combined with the Capitalize utility type, you can automatically generate event handler names from event names. The EventMap example shows how this works in practice: you define your events and their payload types, and a mapped type with template literals generates the complete handler interface automatically.
Continuing in src/advanced_types.ts:
// --- Template Literal Types ---
type Color = "red" | "green" | "blue";
type Shade = "light" | "dark";
// Generates: "light-red" | "light-green" | "light-blue" | "dark-red" | ...
type ColorVariant = `${Shade}-${Color}`;
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type ApiRoute = "/users" | "/posts";
// Generates: "GET /users" | "GET /posts" | "POST /users" | ...
type Endpoint = `${HttpMethod} ${ApiRoute}`;
// Event handler names from properties
type EventFor<T extends string> = `on${Capitalize<T>}`;
type MouseEvent = EventFor<"click" | "move" | "down" | "up">;
// "onClick" | "onMove" | "onDown" | "onUp"
const endpoint: Endpoint = "GET /users";
const color: ColorVariant = "dark-blue";
const event: MouseEvent = "onClick";
console.log(`\nEndpoint: ${endpoint}`);
console.log(`Color: ${color}`);
console.log(`Event: ${event}`);
// Practical: type-safe event emitter shape
type EventMap = {
click: { x: number; y: number };
keypress: { key: string };
resize: { width: number; height: number };
};
type HandlerMap<T extends Record<string, any>> = {
[K in keyof T as `on${Capitalize<string & K>}`]: (event: T[K]) => void;
};
type Handlers = HandlerMap<EventMap>;
// { onClick: (event: {x, y}) => void; onKeypress: ...; onResize: ... }
const handlers: Handlers = {
onClick: (e) => console.log(`Clicked at (${e.x}, ${e.y})`),
onKeypress: (e) => console.log(`Key: ${e.key}`),
onResize: (e) => console.log(`Resized to ${e.width}x${e.height}`),
};
handlers.onClick({ x: 100, y: 200 });
handlers.onKeypress({ key: "Enter" });
handlers.onResize({ width: 1920, height: 1080 });
Sharpen Your Pencil
✏️ Sharpen Your Pencil
- Create
@logand@timeddecorators using the TC39 Stage 3 syntax. Apply them to aMathServiceclass withadd,fibonacci, andmultiplymethods. Stack both decorators onmultiply. - Build a
RequestBuilderclass with a fluent API. Chain.setMethod(),.setUrl(),.addHeader(),.setBody(), and.setTimeout()calls. Call.build()and verify it throws when required fields are missing. - Implement a
Databasesingleton with a private constructor andgetInstance(). Verify that two calls togetInstance()return the same object. Build a genericRegistry<T>that registers and retrieves named items. - Create mapped types
Immutable<T>,Nullable<T>, andStringProps<T>. Apply them to aUserinterface and verify the transformed types work correctly. - Create conditional types
IsString<T>,ElementType<T>, andUnwrap<T>. Test them with different type arguments. - Create template literal types
ColorVariant,Endpoint, andMouseEvent. Build aHandlerMapthat generates event handler interfaces from anEventMaptype.
Remember this one thing: Advanced types let TypeScript's type system model complex relationships that simpler types cannot express.
Up Next
In Module 19: Capstone Project, you will bring everything together -- types, modules, testing, configuration, and patterns -- into a complete, well-structured TypeScript project built from scratch.
Module Quiz
Test what you learned. 45 seconds per question. Your best attempt is saved in your browser so you can track progress -- nothing is sent to a server.