Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Classes & OOP

TypeScript adds powerful features to JavaScript classes: access modifiers, abstract classes, and more. It makes object-oriented programming feel natural and type-safe. While JavaScript classes are essentially syntactic sugar over prototypal inheritance, TypeScript transforms them into proper OOP constructs with compile-time visibility enforcement, abstract contracts, and type-safe generics. If you come from Java or C#, TypeScript classes will feel familiar. If you come from pure JavaScript, they will feel like a major upgrade in safety and expressiveness.

1. Class Basics

Basic Class Structure

class User {
  // Properties
  name: string;
  email: string;

  // Constructor
  constructor(name: string, email: string) {
    this.name = name;
    this.email = email;
  }

  // Methods
  greet(): string {
    return `Hello, I'm ${this.name}`;
  }
}

const user = new User('Alice', 'alice@example.com');
console.log(user.greet()); // "Hello, I'm Alice"

Parameter Properties (Shorthand)

TypeScript can automatically create and assign properties from constructor parameters. This is one of the most loved TypeScript features because it eliminates the tedious boilerplate of declaring a property, adding a constructor parameter, and assigning one to the other:
class User {
  // public keyword creates and assigns the property
  constructor(
    public name: string,
    public email: string,
    public age: number = 0
  ) {}

  greet(): string {
    return `Hello, I'm ${this.name}`;
  }
}

// Equivalent to the longer version above
const user = new User('Alice', 'alice@example.com', 25);

2. Access Modifiers

Control visibility of class members. Access modifiers are the compiler-enforced equivalent of “do not touch” signs. They tell other developers (and the compiler) which parts of a class are part of its public API and which are internal implementation details. This matters enormously for maintainability: if a property is private, you can change or remove it without worrying about breaking consumers.

public (Default)

Accessible from anywhere. This is the default in TypeScript, just like JavaScript.
class User {
  public name: string;

  constructor(name: string) {
    this.name = name;
  }
}

const user = new User('Alice');
console.log(user.name); // ✅ Accessible

private

Only accessible within the class.
class BankAccount {
  private balance: number = 0;

  deposit(amount: number): void {
    this.balance += amount;
  }

  withdraw(amount: number): boolean {
    if (amount > this.balance) return false;
    this.balance -= amount;
    return true;
  }

  getBalance(): number {
    return this.balance;
  }
}

const account = new BankAccount();
account.deposit(100);
// account.balance; // ❌ Error: Property 'balance' is private
console.log(account.getBalance()); // 100

protected

Accessible within the class and subclasses.
class Animal {
  protected name: string;

  constructor(name: string) {
    this.name = name;
  }
}

class Dog extends Animal {
  bark(): string {
    return `${this.name} says Woof!`; // ✅ Can access protected
  }
}

const dog = new Dog('Max');
// dog.name; // ❌ Error: Property 'name' is protected
console.log(dog.bark()); // "Max says Woof!"

Private Fields (ES2022)

True private fields using # syntax.
class Counter {
  #count = 0; // Truly private (runtime enforcement)

  increment(): number {
    return ++this.#count;
  }

  get value(): number {
    return this.#count;
  }
}

const counter = new Counter();
counter.increment();
// counter.#count; // ❌ Syntax error - truly inaccessible
console.log(counter.value); // 1
private vs #: private is TypeScript-only (compile-time). # is JavaScript-native (runtime-enforced). Use # for true encapsulation.

3. Readonly Properties

Properties that can only be set in the constructor.
class User {
  readonly id: number;
  name: string;

  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  changeName(name: string): void {
    this.name = name; // ✅ OK
    // this.id = 999; // ❌ Error: Cannot assign to 'id' because it is read-only
  }
}

Combining with Parameter Properties

class User {
  constructor(
    public readonly id: number,
    public name: string
  ) {}
}

4. Getters and Setters

Define computed properties with validation.
class Circle {
  private _radius: number;

  constructor(radius: number) {
    this._radius = radius;
  }

  // Getter
  get radius(): number {
    return this._radius;
  }

  // Setter with validation
  set radius(value: number) {
    if (value <= 0) {
      throw new Error('Radius must be positive');
    }
    this._radius = value;
  }

  // Computed property (getter only)
  get area(): number {
    return Math.PI * this._radius ** 2;
  }

  get circumference(): number {
    return 2 * Math.PI * this._radius;
  }
}

const circle = new Circle(5);
console.log(circle.area);        // 78.54...
circle.radius = 10;              // Uses setter
console.log(circle.circumference); // 62.83...
// circle.area = 100;            // ❌ Error: Cannot set - no setter

5. Static Members

Properties and methods on the class itself, not instances.
class MathUtils {
  static PI = 3.14159;

  static add(a: number, b: number): number {
    return a + b;
  }

  static multiply(a: number, b: number): number {
    return a * b;
  }

  // Static block (TS 4.4+)
  static {
    console.log('MathUtils class initialized');
  }
}

// Access without instantiation
console.log(MathUtils.PI);           // 3.14159
console.log(MathUtils.add(2, 3));    // 5

// Cannot access on instance
const utils = new MathUtils();
// utils.PI; // ❌ Error

Singleton Pattern

class Database {
  private static instance: Database;
  private constructor() {} // Private constructor prevents new Database()

  static getInstance(): Database {
    if (!Database.instance) {
      Database.instance = new Database();
    }
    return Database.instance;
  }

  query(sql: string): void {
    console.log(`Executing: ${sql}`);
  }
}

const db1 = Database.getInstance();
const db2 = Database.getInstance();
console.log(db1 === db2); // true - same instance

6. Inheritance

Extend classes to create specialized versions.
class Animal {
  constructor(public name: string) {}

  move(distance: number): void {
    console.log(`${this.name} moved ${distance}m`);
  }
}

class Dog extends Animal {
  constructor(name: string, public breed: string) {
    super(name); // Call parent constructor
  }

  bark(): void {
    console.log(`${this.name} says Woof!`);
  }

  // Override parent method
  move(distance: number): void {
    console.log('Running...');
    super.move(distance); // Call parent method
  }
}

const dog = new Dog('Max', 'Golden Retriever');
dog.bark();     // "Max says Woof!"
dog.move(10);   // "Running..." then "Max moved 10m"

Method Overriding with override

class Animal {
  speak(): void {
    console.log('Some sound');
  }
}

class Dog extends Animal {
  override speak(): void { // Explicitly marks override
    console.log('Woof!');
  }

  // override bark(): void {} // ❌ Error: no 'bark' in parent
}

7. Abstract Classes

Define templates for subclasses. Cannot be instantiated directly. Abstract classes are a “contract with benefits” — like an interface, they define what subclasses must implement, but unlike an interface, they can also provide shared implementation code. This is the sweet spot between “pure interface” (no shared code) and “concrete base class” (forces a specific implementation).
abstract class Shape {
  constructor(public color: string) {}

  // Abstract method - must be implemented by subclasses
  abstract getArea(): number;
  abstract getPerimeter(): number;

  // Concrete method - can be inherited
  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;
  }
}

class Rectangle extends Shape {
  constructor(
    color: string,
    public width: number,
    public height: number
  ) {
    super(color);
  }

  getArea(): number {
    return this.width * this.height;
  }

  getPerimeter(): number {
    return 2 * (this.width + this.height);
  }
}

// const shape = new Shape('red'); // ❌ Error: Cannot create instance of abstract class

const circle = new Circle('red', 5);
console.log(circle.describe()); // "A red shape with area 78.54..."

const rect = new Rectangle('blue', 4, 6);
console.log(rect.getArea()); // 24

8. Implementing Interfaces

Classes can implement one or more interfaces. The implements keyword is a compile-time contract: it guarantees that the class provides every property and method the interface requires. If you add a new method to the interface, every implementing class that is missing it immediately gets a compiler error. This is one of the most powerful patterns for enforcing consistency across a codebase.
interface Printable {
  print(): void;
}

interface Loggable {
  log(): void;
}

interface Serializable {
  serialize(): string;
  deserialize(data: string): void;
}

class Document implements Printable, Loggable, Serializable {
  constructor(public content: string) {}

  print(): void {
    console.log(`Printing: ${this.content}`);
  }

  log(): void {
    console.log(`Logging: ${this.content}`);
  }

  serialize(): string {
    return JSON.stringify({ content: this.content });
  }

  deserialize(data: string): void {
    const parsed = JSON.parse(data);
    this.content = parsed.content;
  }
}

Interface vs Abstract Class

FeatureInterfaceAbstract Class
Multiple inheritance✅ Yes❌ No
Implementation code❌ No✅ Yes
Properties with values❌ No✅ Yes
Constructor❌ No✅ Yes
Access modifiers❌ No✅ Yes

9. Generic Classes

Classes that work with multiple types.
class Stack<T> {
  private items: T[] = [];

  push(item: T): void {
    this.items.push(item);
  }

  pop(): T | undefined {
    return this.items.pop();
  }

  peek(): T | undefined {
    return this.items[this.items.length - 1];
  }

  isEmpty(): boolean {
    return this.items.length === 0;
  }

  get size(): number {
    return this.items.length;
  }
}

// Type-safe stacks
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
console.log(numberStack.pop()); // 2

const stringStack = new Stack<string>();
stringStack.push('hello');
stringStack.push('world');
console.log(stringStack.peek()); // 'world'

Generic Constraints in Classes

interface Identifiable {
  id: number;
}

class Repository<T extends Identifiable> {
  private items: T[] = [];

  add(item: T): void {
    this.items.push(item);
  }

  findById(id: number): T | undefined {
    return this.items.find(item => item.id === id);
  }

  remove(id: number): boolean {
    const index = this.items.findIndex(item => item.id === id);
    if (index !== -1) {
      this.items.splice(index, 1);
      return true;
    }
    return false;
  }
}

interface User extends Identifiable {
  name: string;
}

const userRepo = new Repository<User>();
userRepo.add({ id: 1, name: 'Alice' });
console.log(userRepo.findById(1)); // { id: 1, name: 'Alice' }

10. Decorators

Decorators are a way to add metadata or modify class behavior declaratively using @ syntax. They are heavily used in frameworks like NestJS, Angular, and TypeORM. TypeScript has had experimental support for years, and the TC39 proposal recently reached Stage 3, meaning the syntax is stabilizing. Decorators shine when you want to add cross-cutting concerns (logging, validation, caching, authorization) without polluting business logic.
Enable decorators in tsconfig.json:
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Class Decorator

function Logger(constructor: Function) {
  console.log(`Creating instance of ${constructor.name}`);
}

@Logger
class User {
  constructor(public name: string) {}
}

const user = new User('Alice'); // Logs: "Creating instance of User"

Decorator Factory

function Logger(prefix: string) {
  return function (constructor: Function) {
    console.log(`${prefix}: ${constructor.name}`);
  };
}

@Logger('INFO')
class User {
  constructor(public name: string) {}
}

Method Decorator

function Log(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  const originalMethod = descriptor.value;

  descriptor.value = function (...args: any[]) {
    console.log(`Calling ${propertyKey} with args: ${JSON.stringify(args)}`);
    const result = originalMethod.apply(this, args);
    console.log(`${propertyKey} returned: ${result}`);
    return result;
  };

  return descriptor;
}

class Calculator {
  @Log
  add(a: number, b: number): number {
    return a + b;
  }
}

const calc = new Calculator();
calc.add(2, 3);
// Logs: "Calling add with args: [2,3]"
// Logs: "add returned: 5"

Property Decorator

function Required(target: any, propertyKey: string) {
  let value: any;

  const getter = function () {
    return value;
  };

  const setter = function (newVal: any) {
    if (newVal === undefined || newVal === null || newVal === '') {
      throw new Error(`${propertyKey} is required`);
    }
    value = newVal;
  };

  Object.defineProperty(target, propertyKey, {
    get: getter,
    set: setter,
    enumerable: true,
    configurable: true
  });
}

class User {
  @Required
  name!: string;
}

const user = new User();
// user.name = ''; // ❌ Throws: "name is required"
user.name = 'Alice'; // ✅ OK

11. Practical Example: Service Layer

// Interfaces
interface Entity {
  id: number;
  createdAt: Date;
  updatedAt: Date;
}

interface UserEntity extends Entity {
  name: string;
  email: string;
  role: 'admin' | 'user';
}

// Base Repository
abstract class BaseRepository<T extends Entity> {
  protected items: T[] = [];

  abstract create(data: Omit<T, 'id' | 'createdAt' | 'updatedAt'>): T;

  findAll(): T[] {
    return [...this.items];
  }

  findById(id: number): T | undefined {
    return this.items.find(item => item.id === id);
  }

  update(id: number, data: Partial<T>): T | undefined {
    const index = this.items.findIndex(item => item.id === id);
    if (index === -1) return undefined;

    this.items[index] = {
      ...this.items[index],
      ...data,
      updatedAt: new Date()
    };
    return this.items[index];
  }

  delete(id: number): boolean {
    const index = this.items.findIndex(item => item.id === id);
    if (index === -1) return false;
    this.items.splice(index, 1);
    return true;
  }
}

// User Repository
class UserRepository extends BaseRepository<UserEntity> {
  private nextId = 1;

  create(data: Omit<UserEntity, 'id' | 'createdAt' | 'updatedAt'>): UserEntity {
    const user: UserEntity = {
      ...data,
      id: this.nextId++,
      createdAt: new Date(),
      updatedAt: new Date()
    };
    this.items.push(user);
    return user;
  }

  findByEmail(email: string): UserEntity | undefined {
    return this.items.find(user => user.email === email);
  }

  findByRole(role: 'admin' | 'user'): UserEntity[] {
    return this.items.filter(user => user.role === role);
  }
}

// Usage
const userRepo = new UserRepository();

const alice = userRepo.create({
  name: 'Alice',
  email: 'alice@example.com',
  role: 'admin'
});

const bob = userRepo.create({
  name: 'Bob',
  email: 'bob@example.com',
  role: 'user'
});

console.log(userRepo.findByRole('admin')); // [alice]
console.log(userRepo.findByEmail('bob@example.com')); // bob

Summary

ConceptExample
Class Definitionclass User { name: string }
Constructorconstructor(name: string) {}
Parameter Propertyconstructor(public name: string) {}
Access Modifierspublic, private, protected
Private Fields#privateField
Readonlyreadonly id: number
Getters/Settersget value(), set value(v)
Static Membersstatic PI = 3.14
Inheritanceclass Dog extends Animal
Abstract Classabstract class Shape
Implement Interfaceclass Doc implements Printable
Generic Classclass Stack<T>
Decorators@Logger class User
Next, we’ll dive deep into advanced types!

Interview Deep-Dive

Strong Answer:This question tests whether you understand the difference between compile-time and runtime enforcement, which is a fundamental TypeScript concept.
  • private keyword (TypeScript-only): Enforced only at compile time. The compiled JavaScript has no concept of private — the property is a regular public property on the object. If someone casts to any or accesses the object from plain JavaScript, the “private” field is fully accessible. (account as any).balance bypasses the protection entirely. However, private integrates naturally with TypeScript’s type system, works with access modifier parameter properties (constructor(private balance: number)), and produces clear error messages.
  • # private fields (JavaScript-native): Enforced at runtime by the JavaScript engine. #balance is truly inaccessible from outside the class — no cast, no escape hatch, no workaround. account.#balance is a syntax error even in plain JavaScript. The engine uses a WeakMap-like mechanism internally to make the field genuinely unreachable.
  • When to choose private: When you are writing a fully TypeScript codebase where every consumer goes through the type system. It is simpler syntax, integrates with parameter properties, and the IDE experience is slightly better (autocomplete correctly hides private members). Most application code falls here.
  • When to choose #: When you are writing a library consumed by JavaScript users, when you need security guarantees (the field cannot be accessed even by malicious or buggy code), or when the object will be serialized/deserialized and you need the private field to survive the round trip without appearing in JSON.stringify() output. # fields are excluded from for...in, Object.keys(), and JSON.stringify().
  • The gotcha with #: Private fields are per-class, not per-instance. A method in class BankAccount can access other.#balance if other is also a BankAccount instance. This is consistent with how private fields work in Java and C++ but surprises JavaScript developers.
Follow-up: Can you use # private fields with TypeScript’s parameter properties shorthand?No. constructor(#balance: number) is a syntax error. Parameter properties only work with TypeScript’s access modifiers (public, private, protected, readonly). If you want # private fields, you must declare them explicitly in the class body and assign in the constructor. This is one of the practical friction points that keeps many teams on private even though # is technically superior for encapsulation.
Strong Answer:Abstract classes and interfaces are both about defining contracts, but they serve different architectural purposes and have different technical implications.
  • Abstract classes provide implementation sharing: An abstract class can have concrete methods with real code that subclasses inherit. abstract class Shape { describe(): string { return 'A ' + this.color + ' shape'; } abstract getArea(): number; }describe() has a shared implementation, getArea() must be implemented by each subclass. Interfaces cannot provide any implementation.
  • Interfaces support multiple inheritance: A class can implement multiple interfaces (class Doc implements Printable, Serializable, Loggable), but it can only extend one abstract class. If you need to compose behaviors from multiple sources, interfaces are the only option.
  • Abstract classes have runtime presence: They compile to JavaScript classes. You can use instanceof on them: if (shape instanceof Shape) works. Interfaces are erased — they do not exist at runtime. You cannot instanceof check an interface.
  • Abstract classes carry constructor constraints: If abstract class Repository has a constructor that takes a DatabaseConnection, every subclass must call super(connection). This enforces initialization requirements. Interfaces have no constructors.
  • My decision framework: Use an abstract class when you have shared implementation code and a clear “is-a” hierarchy (a Circle IS a Shape). Use an interface when you are defining a capability contract (“can be printed,” “can be serialized”) that unrelated classes might independently satisfy. In practice, lean toward interfaces for dependency injection boundaries (your service depends on interface UserRepository, not abstract class BaseRepository) because interfaces are lighter and more flexible for testing.
Follow-up: Can you use both together? When would that make sense?Yes, and this is a common pattern in well-designed TypeScript applications. Define an interface for the contract (interface Repository<T> { find, create, update, delete }), then create an abstract class that partially implements it (abstract class BaseRepository<T> implements Repository<T> with shared logic for caching or logging), and finally concrete classes that extend the abstract class (class UserRepository extends BaseRepository<User>). Consuming code depends on the interface, enabling mock implementations for testing. The abstract class provides DRY implementation for production classes. This layered approach gives you the best of both worlds: clean contracts at the boundary and shared implementation internally.
Strong Answer:Decorators are a metaprogramming feature that lets you modify classes, methods, and properties at definition time. They are used extensively in frameworks like Angular, NestJS, and TypeORM.
  • How they work: A decorator is a function that receives the thing it decorates (a class constructor, a method descriptor, or a property descriptor) and can modify or replace it. @Logger class User {} calls Logger(User) at class definition time. A method decorator like @Log add(a, b) receives the target object, the property name, and the property descriptor, and can wrap the original method with additional behavior (logging, validation, caching).
  • Practical uses: Dependency injection (Angular’s @Injectable()), route definition (NestJS’s @Get('/users')), ORM column mapping (TypeORM’s @Column()), validation (@Required, @MinLength(3)), and cross-cutting concerns like logging, caching, and rate limiting. Decorators separate infrastructure concerns from business logic.
  • The status complication: TypeScript has two decorator implementations. The “legacy” implementation (experimentalDecorators: true in tsconfig) follows an older TC39 proposal and is what Angular and NestJS use today. The “modern” implementation (TypeScript 5.0+) follows the TC39 Stage 3 proposal with different semantics (decorators receive a context object instead of the three-argument target, propertyKey, descriptor). These two are not compatible. If you are starting a new project without a framework mandate, use the Stage 3 decorators. If you use Angular or NestJS, you must use the legacy decorators until those frameworks migrate.
  • The gotcha: Decorator factories (decorators that take arguments) return the actual decorator function. @Logger('INFO') first calls Logger('INFO'), which returns a function, and that function is then called with the class constructor. Missing the factory pattern is a common bug: @Logger('INFO') works, but @Logger (without arguments when it expects them) passes the class constructor as the first argument instead of the prefix string, producing confusing behavior.
Follow-up: How would you implement a simple caching decorator for a class method?You would write a method decorator that replaces the original method with a wrapper. The wrapper checks a Map keyed by the serialized arguments. If the key exists, return the cached value. If not, call the original method, store the result, and return it. The decorator has access to descriptor.value (the original function) and replaces it: descriptor.value = function(...args) { const key = JSON.stringify(args); if (cache.has(key)) return cache.get(key); const result = original.apply(this, args); cache.set(key, result); return result; }. The this context must be forwarded with apply, not lost through a bare function call. The cache lifetime should be considered — a naive implementation leaks memory. In production, you would add TTL expiration or LRU eviction.
Strong Answer:The TypeScript Singleton uses private constructor() to prevent external instantiation and a static method to control access to the single instance.
  • Implementation: private constructor() makes new Database() a compile error outside the class. static getInstance() lazily creates the instance on first call and returns the cached instance on subsequent calls. private static instance: Database holds the reference. All consumers call Database.getInstance() and receive the same object.
  • When it is appropriate: Database connection pools, configuration managers, and logger instances — resources that are expensive to create, shared across the application, and where having multiple instances would cause problems (multiple connection pools exhausting database connections, conflicting configuration states). In JavaScript specifically, module-level singletons are common because Node.js caches modules — export const db = new Database() creates a singleton implicitly because the module is only loaded once.
  • When it is harmful: When it creates hidden dependencies and makes testing difficult. If UserService.getInstance() internally calls Database.getInstance(), you cannot test UserService without a real database. The Singleton hides the dependency — it is not in the constructor signature, so you cannot inject a mock. This is the primary argument against Singletons in modern architecture.
  • The modern alternative: Dependency injection. Instead of UserService reaching for Database.getInstance(), the constructor takes Database as a parameter: constructor(private db: Database). In production, a DI container provides the real database. In tests, you inject a mock. The “single instance” guarantee moves from the class itself to the DI container’s configuration (register as singleton scope). This gives you the same runtime behavior with full testability.
Follow-up: Does the private constructor pattern work at runtime in JavaScript, or is it just a TypeScript compile-time check?private is TypeScript-only and erased at runtime. In the compiled JavaScript, the constructor is a regular public constructor, and new Database() works fine from JavaScript code. To enforce the Singleton at runtime, you would need to check inside the constructor: if (Database.instance) throw new Error('Use getInstance()'), or use # private fields (which have runtime enforcement). This is another case where TypeScript’s compile-time guarantees do not automatically translate to runtime safety — important if your class is consumed by JavaScript code or if you are concerned about reflection-based access.