Back to Articles
18 min read

Advanced TypeScript Patterns: Interfaces, Generics, and Modular Architecture

Moving beyond basic types, this guide explores the structural pillars of TypeScript. We contrast Interfaces vs. Type Aliases, implement robust OOP with Classes, and unlock type safety at scale using Generics, Keyof, and Strict Mode configurations.

Interfaces

Interfaces define contracts for object shapes, specifying what properties and methods an object must have without implementing them. They're purely compile-time constructs that disappear after transpilation, serving as powerful tools for type-checking and IDE autocompletion.

interface User { id: number; name: string; email?: string; // Optional property greet(): string; // Method signature } const user: User = { id: 1, name: "Alice", greet() { return `Hello, ${this.name}`; } };

Interface vs Type Alias

Both can define object shapes, but interfaces are extendable and mergeable (declaration merging), while type aliases are more flexible for unions, intersections, primitives, and tuples. Use interfaces for object contracts and public APIs; use types for complex type compositions.

// Interface - can be extended and merged interface Animal { name: string; } interface Animal { age: number; } // Declaration merging ✓ // Type alias - more flexible type ID = string | number; // Union type Point = [number, number]; // Tuple type Callback = (data: string) => void; // Function // Both work for objects interface IUser { name: string; } type TUser = { name: string; }; /* ┌─────────────────────────────────────────────────────────┐ │ Feature │ Interface │ Type Alias │ ├─────────────────────────────────────────────────────────┤ │ Extend/Inherit │ ✓ │ ✓ (intersection)│ │ Declaration Merge │ ✓ │ ✗ │ │ Unions/Tuples │ ✗ │ ✓ │ │ Primitives │ ✗ │ ✓ │ │ Computed Props │ ✗ │ ✓ │ └─────────────────────────────────────────────────────────┘ */

Extending Interfaces

Interfaces can inherit from one or multiple interfaces using the extends keyword, allowing you to build complex types from simpler ones while maintaining clean, modular code. This promotes composition and reusability across your codebase.

interface Person { name: string; age: number; } interface Employee extends Person { employeeId: string; department: string; } // Multiple inheritance interface Manager extends Employee, Person { teamSize: number; } const manager: Manager = { name: "Bob", age: 35, employeeId: "E123", department: "Engineering", teamSize: 10 }; /* ┌──────────┐ │ Person │ └────┬─────┘ │ extends ┌────▼─────┐ │ Employee │ └────┬─────┘ │ extends ┌────▼─────┐ │ Manager │ └──────────┘ */

Classes

TypeScript classes extend JavaScript classes with static typing, access modifiers, and interfaces implementation, providing a robust OOP foundation. They support constructors, properties, methods, inheritance, and can implement multiple interfaces for contract enforcement.

interface Printable { print(): void; } class Document implements Printable { constructor( public title: string, // Shorthand property declaration private content: string ) {} print(): void { console.log(`${this.title}: ${this.content}`); } } class Report extends Document { constructor(title: string, content: string, public author: string) { super(title, content); } } const report = new Report("Q4 Report", "Sales data...", "Alice"); report.print();

Access Modifiers (public, private, protected)

Access modifiers control visibility of class members: public (default) allows access everywhere, private restricts to the class itself, and protected allows access within the class and its subclasses. These enforce encapsulation at compile-time.

class BankAccount { public accountHolder: string; // Accessible everywhere private balance: number; // Only within this class protected accountType: string; // This class + subclasses constructor(holder: string, initial: number) { this.accountHolder = holder; this.balance = initial; this.accountType = "Checking"; } public getBalance(): number { return this.balance; // ✓ Private accessible inside } } class SavingsAccount extends BankAccount { showType(): string { return this.accountType; // ✓ Protected accessible in subclass // return this.balance; // ✗ Error: private } } /* ┌────────────────────────────────────────────────┐ │ Modifier │ Class │ Subclass │ Outside │ ├────────────────────────────────────────────────┤ │ public │ ✓ │ ✓ │ ✓ │ │ protected │ ✓ │ ✓ │ ✗ │ │ private │ ✓ │ ✗ │ ✗ │ └────────────────────────────────────────────────┘ */

Readonly Properties

The readonly modifier prevents reassignment of properties after initialization (constructor or declaration), creating immutable fields. It works with classes, interfaces, and type aliases, enforced at compile-time only.

interface Config { readonly apiKey: string; readonly endpoint: string; } class ImmutablePoint { readonly x: number; readonly y: number; constructor(x: number, y: number) { this.x = x; // ✓ Allowed in constructor this.y = y; } move() { // this.x = 10; // ✗ Error: Cannot assign to 'x' } } // Readonly utility type const config: Readonly<{ host: string; port: number }> = { host: "localhost", port: 3000 }; // config.port = 8080; // ✗ Error

Getters and Setters

Getters and setters (accessors) provide controlled access to properties, allowing validation, computation, or side effects when reading/writing values. They appear as regular properties but execute code behind the scenes.

class Temperature { private _celsius: number = 0; // Getter - computed property get fahrenheit(): number { return (this._celsius * 9/5) + 32; } // Setter - with validation set fahrenheit(value: number) { if (value < -459.67) { throw new Error("Below absolute zero!"); } this._celsius = (value - 32) * 5/9; } get celsius(): number { return this._celsius; } set celsius(value: number) { this._celsius = value; } } const temp = new Temperature(); temp.celsius = 100; console.log(temp.fahrenheit); // 212 (accessed like property, not method) temp.fahrenheit = 32; console.log(temp.celsius); // 0

Static Members

Static properties and methods belong to the class itself rather than instances, accessed via the class name. They're useful for utility functions, constants, factory methods, and tracking shared state across all instances.

class MathUtils { static readonly PI = 3.14159; private static instanceCount = 0; static square(n: number): number { return n * n; } static getInstanceCount(): number { return MathUtils.instanceCount; } constructor() { MathUtils.instanceCount++; } } // Accessed via class name, no instantiation needed console.log(MathUtils.PI); // 3.14159 console.log(MathUtils.square(5)); // 25 new MathUtils(); new MathUtils(); console.log(MathUtils.getInstanceCount()); // 2 /* ┌──────────────────────────────────────┐ │ MathUtils (Class) │ ├──────────────────────────────────────┤ │ static PI = 3.14159 │ │ static instanceCount = 0 │ │ static square(n) │ ├──────────────────────────────────────┤ │ Instance 1 │ Instance 2 │ │ (no static access from here) │ └──────────────────────────────────────┘ */

Abstract Classes

Abstract classes serve as base classes that cannot be instantiated directly, containing both implemented methods and abstract method signatures that subclasses must implement. They provide a template pattern, enforcing structure while allowing shared functionality.

abstract class Shape { constructor(public color: string) {} // Abstract method - must be implemented by subclasses abstract getArea(): number; abstract getPerimeter(): number; // Concrete method - shared implementation describe(): string { return `A ${this.color} shape with area ${this.getArea()}`; } } class Circle extends Shape { constructor(color: string, public radius: number) { super(color); } getArea(): number { return Math.PI * this.radius ** 2; } getPerimeter(): number { return 2 * Math.PI * this.radius; } } // const shape = new Shape("red"); // ✗ Error: Cannot instantiate abstract class const circle = new Circle("blue", 5); console.log(circle.describe()); // "A blue shape with area 78.54..."

Generics

Generics enable writing reusable, type-safe code that works with multiple types while preserving type information throughout. They act as type variables (typically T, U, K, V) that are specified when the generic is used, avoiding any while maintaining flexibility.

// Generic function function identity<T>(arg: T): T { return arg; } const num = identity<number>(42); // Type: number const str = identity("hello"); // Type inferred: string // Generic array wrapper function firstElement<T>(arr: T[]): T | undefined { return arr[0]; } const first = firstElement([1, 2, 3]); // Type: number | undefined /* ┌─────────────────────────────────────────────┐ │ Without Generics │ With Generics │ ├─────────────────────────────────────────────┤ │ function id(x: any) │ function id<T>(x:T)│ │ Returns: any │ Returns: T │ │ Type info: LOST │ Type info: KEPT │ └─────────────────────────────────────────────┘ */

Generic Functions

Generic functions declare type parameters that are resolved at call time, enabling type-safe operations across different types. The compiler infers types from arguments when possible, or you can explicitly specify them for clarity.

// Multiple type parameters function pair<T, U>(first: T, second: U): [T, U] { return [first, second]; } const p1 = pair("hello", 42); // [string, number] const p2 = pair<string, boolean>("a", true); // Generic with arrays function map<T, U>(arr: T[], fn: (item: T) => U): U[] { return arr.map(fn); } const numbers = [1, 2, 3]; const strings = map(numbers, n => n.toString()); // string[] // Generic arrow function const getProperty = <T, K extends keyof T>(obj: T, key: K): T[K] => { return obj[key]; }; const user = { name: "Alice", age: 30 }; const name = getProperty(user, "name"); // string

Generic Classes

Generic classes define type parameters at the class level, making them available to all properties, methods, and constructors. They're essential for creating reusable data structures like collections, repositories, and containers.

class Container<T> { private items: T[] = []; add(item: T): void { this.items.push(item); } get(index: number): T { return this.items[index]; } getAll(): T[] { return [...this.items]; } } const numberBox = new Container<number>(); numberBox.add(1); numberBox.add(2); // numberBox.add("three"); // ✗ Error: string not assignable to number const stringBox = new Container<string>(); stringBox.add("hello"); // Multiple type parameters class KeyValueStore<K, V> { private store = new Map<K, V>(); set(key: K, value: V): void { this.store.set(key, value); } get(key: K): V | undefined { return this.store.get(key); } } const cache = new KeyValueStore<string, number>(); cache.set("count", 42);

Generic Constraints

Constraints limit generic types using extends, ensuring the type parameter has certain properties or structure. This enables accessing specific properties/methods within the generic while maintaining flexibility.

// Constraint with interface interface HasLength { length: number; } function logLength<T extends HasLength>(item: T): number { console.log(item.length); // ✓ Safe: T guaranteed to have length return item.length; } logLength("hello"); // 5 logLength([1, 2, 3]); // 3 logLength({ length: 10 }); // 10 // logLength(123); // ✗ Error: number has no length // Constraint with keyof function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const person = { name: "Alice", age: 30 }; getProperty(person, "name"); // ✓ // getProperty(person, "foo"); // ✗ Error: "foo" not in keyof person // Multiple constraints function merge<T extends object, U extends object>(a: T, b: U): T & U { return { ...a, ...b }; }

Utility Types (Partial, Required, Pick, Omit, Record, etc.)

TypeScript provides built-in utility types that transform existing types, reducing boilerplate and enabling common type manipulations. These are generic types that take type parameters and return modified versions.

interface User { id: number; name: string; email: string; age?: number; } // Partial<T> - All properties optional type PartialUser = Partial<User>; const update: PartialUser = { name: "Bob" }; // ✓ // Required<T> - All properties required type RequiredUser = Required<User>; // age is now required // Pick<T, K> - Select specific properties type UserCredentials = Pick<User, "email" | "id">; // Omit<T, K> - Exclude properties type PublicUser = Omit<User, "id">; // Record<K, V> - Create object type with key type K and value type V type RolePermissions = Record<"admin" | "user" | "guest", boolean>; const perms: RolePermissions = { admin: true, user: true, guest: false }; // Readonly<T>, NonNullable<T>, ReturnType<T>, Parameters<T> type ReadonlyUser = Readonly<User>; /* ┌──────────────────────────────────────────────────────┐ │ Utility Type │ Effect │ ├──────────────────────────────────────────────────────┤ │ Partial<T> │ Makes all props optional │ │ Required<T> │ Makes all props required │ │ Readonly<T> │ Makes all props readonly │ │ Pick<T,K> │ Selects subset of props │ │ Omit<T,K> │ Removes specified props │ │ Record<K,V> │ Creates {[key: K]: V} │ │ Exclude<T,U> │ Excludes types from union │ │ Extract<T,U> │ Extracts types from union │ │ NonNullable<T> │ Removes null/undefined │ │ ReturnType<T> │ Gets function return type │ │ Parameters<T> │ Gets function params as tuple │ └──────────────────────────────────────────────────────┘ */

Keyof Operator

The keyof operator extracts the union of all property names (keys) from a type as string literal types. It's essential for creating type-safe property access patterns and generic constraints.

interface Product { id: number; name: string; price: number; inStock: boolean; } // keyof extracts keys as union type type ProductKeys = keyof Product; // "id" | "name" | "price" | "inStock" function getValue<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const product: Product = { id: 1, name: "Laptop", price: 999, inStock: true }; const name = getValue(product, "name"); // Type: string const price = getValue(product, "price"); // Type: number // getValue(product, "foo"); // ✗ Error // With index signatures type Dictionary = { [key: string]: number }; type DictKeys = keyof Dictionary; // string | number (number keys coerced to strings) // keyof with typeof for objects const config = { host: "localhost", port: 3000 }; type ConfigKeys = keyof typeof config; // "host" | "port"

Typeof Operator

TypeScript's typeof in type context extracts the type from a value (variable, function, etc.), enabling type inference from existing runtime values. This is different from JavaScript's runtime typeof operator.

// Extract type from variable const user = { name: "Alice", age: 30, roles: ["admin", "user"] as const }; type User = typeof user; // { name: string; age: number; roles: readonly ["admin", "user"] } // Extract function type function createPoint(x: number, y: number) { return { x, y }; } type PointFactory = typeof createPoint; // (x: number, y: number) => { x: number; y: number } type Point = ReturnType<typeof createPoint>; // { x: number; y: number } // Combine with keyof const ColorMap = { red: "#FF0000", green: "#00FF00", blue: "#0000FF" } as const; type ColorName = keyof typeof ColorMap; // "red" | "green" | "blue" type ColorValue = typeof ColorMap[ColorName]; // "#FF0000" | "#00FF00" | "#0000FF"

Indexed Access Types

Indexed access types (T[K]) allow you to look up the type of a specific property on another type, similar to accessing object properties at runtime. This enables extracting and reusing nested types dynamically.

interface ApiResponse { data: { users: { id: number; name: string }[]; metadata: { total: number; page: number }; }; status: number; message: string; } // Access nested types type ResponseData = ApiResponse["data"]; type Users = ApiResponse["data"]["users"]; // { id: number; name: string }[] type SingleUser = ApiResponse["data"]["users"][number]; // { id: number; name: string } type Metadata = ApiResponse["data"]["metadata"]; // Multiple keys at once type StatusOrMessage = ApiResponse["status" | "message"]; // number | string // With arrays type StringArray = string[]; type StringElement = StringArray[number]; // string // With tuples type Tuple = [string, number, boolean]; type First = Tuple[0]; // string type Second = Tuple[1]; // number // Combining with keyof type PropTypes<T> = T[keyof T]; // Union of all property types type ApiValues = PropTypes<ApiResponse>; // data | number | string

Modules and Namespaces

Modules (ES modules) are the modern standard for code organization using import/export, while namespaces (internal modules) are TypeScript's legacy solution for grouping code. Prefer modules; use namespaces only for global script augmentation or ambient declarations.

// ══════ NAMESPACES (Legacy) ══════ namespace Validation { export interface Validator { validate(value: string): boolean; } export class EmailValidator implements Validator { validate(email: string): boolean { return email.includes("@"); } } } const validator = new Validation.EmailValidator(); // Nested namespaces namespace App { export namespace Utils { export function log(msg: string) { console.log(msg); } } } App.Utils.log("Hello"); /* ┌─────────────────────────────────────────────────────┐ │ Modules (ES) │ Namespaces (Legacy) │ ├─────────────────────────────────────────────────────┤ │ File-based scope │ Global/declared scope │ │ import/export │ namespace keyword │ │ Static analysis │ Runtime object │ │ Tree-shaking │ No tree-shaking │ │ Recommended ✓ │ Avoid unless necessary │ └─────────────────────────────────────────────────────┘ */

ES Modules in TypeScript

TypeScript fully supports ES module syntax (import/export), compiling to various module formats (CommonJS, ESM, AMD) based on tsconfig.json. Use named exports for most cases and default exports sparingly for main module exports.

// ══════ math.ts ══════ export const PI = 3.14159; export function add(a: number, b: number): number { return a + b; } export interface Calculator { calculate(a: number, b: number): number; } export default class AdvancedMath { static power(base: number, exp: number): number { return base ** exp; } } // ══════ app.ts ══════ import AdvancedMath, { PI, add, type Calculator } from "./math"; import * as MathUtils from "./math"; // Namespace import console.log(add(2, 3)); console.log(AdvancedMath.power(2, 10)); console.log(MathUtils.PI); // Re-exports export { add as addition } from "./math"; export * from "./math"; export * as Math from "./math"; // Type-only imports (erased at runtime) import type { Calculator } from "./math";

Declaration Files (.d.ts)

Declaration files contain only type information without implementation, describing the shape of JavaScript libraries for TypeScript. They enable type checking and IntelliSense for JS code, npm packages, or global APIs without converting to TypeScript.

// ══════ my-library.d.ts ══════ // Declaring a module declare module "my-library" { export interface Config { apiKey: string; timeout?: number; } export function initialize(config: Config): void; export function fetchData<T>(url: string): Promise<T>; export class Client { constructor(config: Config); get<T>(endpoint: string): Promise<T>; } const defaultExport: Client; export default defaultExport; } // Declaring global variables declare const API_VERSION: string; declare function globalHelper(x: number): string; // Declaring a global namespace (e.g., browser APIs) declare namespace NodeJS { interface ProcessEnv { NODE_ENV: "development" | "production" | "test"; API_KEY: string; } } /* Source: Declaration: ┌──────────────┐ ┌──────────────┐ │ library.js │ ──► │ library.d.ts │ │ (runtime) │ │ (types only) │ └──────────────┘ └──────────────┘ */

Triple-Slash Directives

Triple-slash directives are single-line comments containing XML tags that serve as compiler instructions, used primarily for declaring dependencies between files, referencing declaration files, or configuring specific file behavior.

/// <reference path="./globals.d.ts" /> /// <reference types="node" /> /// <reference lib="es2020" /> /// <reference no-default-lib="true"/> /* ┌────────────────────────────────────────────────────────────────┐ │ Directive │ Purpose │ ├────────────────────────────────────────────────────────────────┤ │ /// <reference path> │ Declare dependency on another file │ │ /// <reference types> │ Include @types/package declarations │ │ /// <reference lib> │ Include built-in lib (es2020, dom) │ │ /// <reference no- │ Exclude default lib.d.ts │ │ default-lib> │ │ │ /// <amd-module> │ Set AMD module name │ │ /// <amd-dependency> │ Declare AMD dependency │ └────────────────────────────────────────────────────────────────┘ */ // Example: globals.d.ts declare const BUILD_VERSION: string; // Example: Using types from @types/node /// <reference types="node" /> import { readFileSync } from "fs"; // Note: With ES modules, prefer import statements over triple-slash // Triple-slash mainly used in .d.ts files or non-module scripts

Strict Mode Options

Strict mode ("strict": true) enables a family of rigorous type-checking options that catch more errors at compile time. Individual flags can be toggled for gradual adoption or specific project needs in tsconfig.json.

// tsconfig.json { "compilerOptions": { "strict": true, // Enables ALL below: // Individual strict flags: "noImplicitAny": true, // Error on implied 'any' "strictNullChecks": true, // null/undefined not in every type "strictFunctionTypes": true, // Stricter function type checking "strictBindCallApply": true, // Check bind/call/apply types "strictPropertyInitialization": true, // Class props must initialize "noImplicitThis": true, // Error on 'this' of type 'any' "useUnknownInCatchVariables": true, // catch(e) is unknown "alwaysStrict": true // Emit "use strict" } } // Examples of what strict catches: // noImplicitAny function bad(x) {} // ✗ Error: parameter 'x' implicitly has 'any' function good(x: number) {} // ✓ // strictNullChecks let name: string; name = null; // ✗ Error: null not assignable to string let maybeName: string | null = null; // ✓ // strictPropertyInitialization class User { name: string; // ✗ Error: not initialized age!: number; // ✓ Definite assignment assertion constructor(public email: string) {} // email ✓ } /* Recommended: Always use "strict": true for new projects! */