TypeScript Blitz - Day 7: Classes & OOP

Master object-oriented programming in TypeScript - classes, inheritance, abstract classes, generics, and design patterns for real-world applications

24 min read

Goal

After this lesson you’ll master object-oriented programming in TypeScript, using classes with type safety for real-world applications.


Theory Part (20 min)

1. Classes - Basic Syntax

// Basic class definition
class User {
  id: number;
  name: string;
  email: string;
  
  constructor(id: number, name: string, email: string) {
    this.id = id;
    this.name = name;
    this.email = email;
  }
  
  greet(): string {
    return `Hello, I'm ${this.name}`;
  }
  
  updateEmail(email: string): void {
    this.email = email;
  }
}

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

// Type of instance
const user2: User = new User(2, "Jane", "jane@example.com");

// Class as type
function processUser(user: User): void {
  console.log(user.name);
}

2. Access Modifiers - public, private, protected

class BankAccount {
  public accountNumber: string;    // Accessible everywhere
  private balance: number;          // Only within this class
  protected owner: string;          // Within this class and subclasses
  
  constructor(accountNumber: string, owner: string, initialBalance: number) {
    this.accountNumber = accountNumber;
    this.owner = owner;
    this.balance = initialBalance;
  }
  
  // Public method
  public deposit(amount: number): void {
    this.balance += amount;
  }
  
  // Private method
  private calculateInterest(): number {
    return this.balance * 0.05;
  }
  
  // Protected method
  protected validateAmount(amount: number): boolean {
    return amount > 0;
  }
  
  public getBalance(): number {
    return this.balance;
  }
}

const account = new BankAccount("123456", "John", 1000);
console.log(account.accountNumber); // ✅ OK - public
// console.log(account.balance);    // ❌ Error - private
// console.log(account.owner);      // ❌ Error - protected

account.deposit(500);                // ✅ OK - public method
// account.calculateInterest();      // ❌ Error - private method
console.log(account.getBalance());   // ✅ OK - 1500

// Subclass can access protected
class SavingsAccount extends BankAccount {
  public getOwner(): string {
    return this.owner; // ✅ OK - protected is accessible in subclass
  }
  
  public addBonus(amount: number): void {
    if (this.validateAmount(amount)) { // ✅ OK - protected method
      this.deposit(amount);
    }
  }
}

Default: If no modifier is specified, members are public by default.


3. Parameter Properties - Shorthand Syntax

// Traditional way - verbose
class User {
  id: number;
  name: string;
  email: string;
  
  constructor(id: number, name: string, email: string) {
    this.id = id;
    this.name = name;
    this.email = email;
  }
}

// Parameter properties - concise
class User2 {
  constructor(
    public id: number,
    public name: string,
    public email: string
  ) {
    // Properties are automatically created and assigned
  }
}

// Both create the same class structure!
const user = new User2(1, "John", "john@example.com");
console.log(user.id);   // 1
console.log(user.name); // "John"

// Works with all access modifiers
class Product {
  constructor(
    public id: number,
    public name: string,
    private cost: number,
    protected supplier: string
  ) {}
  
  public getProfit(sellingPrice: number): number {
    return sellingPrice - this.cost; // ✅ Can access private cost
  }
}

4. Readonly Properties

class User {
  readonly id: number;           // Cannot be changed after initialization
  readonly createdAt: Date;
  public name: string;           // Can be changed
  
  constructor(id: number, name: string) {
    this.id = id;
    this.createdAt = new Date(); // Set once in constructor
    this.name = name;
  }
  
  updateName(name: string): void {
    this.name = name;            // ✅ OK
    // this.id = 999;            // ❌ Error - readonly
    // this.createdAt = new Date(); // ❌ Error - readonly
  }
}

// Readonly with parameter properties
class Product {
  constructor(
    readonly id: string,
    readonly sku: string,
    public name: string,
    public price: number
  ) {}
}

const product = new Product("1", "SKU-001", "Laptop", 999);
// product.id = "2";    // ❌ Error - readonly
product.name = "Desktop"; // ✅ OK

5. Getters and Setters

class User {
  private _age: number;
  
  constructor(
    public name: string,
    age: number
  ) {
    this._age = age;
  }
  
  // Getter - accessed like a property
  get age(): number {
    return this._age;
  }
  
  // Setter - assigned like a property
  set age(value: number) {
    if (value < 0 || value > 150) {
      throw new Error("Invalid age");
    }
    this._age = value;
  }
  
  // Computed property with getter only (read-only)
  get birthYear(): number {
    return new Date().getFullYear() - this._age;
  }
}

const user = new User("John", 30);

// Use like properties, not methods
console.log(user.age);      // 30 (calls getter)
user.age = 31;              // (calls setter)
console.log(user.age);      // 31

console.log(user.birthYear); // Computed value
// user.birthYear = 1990;    // ❌ Error - no setter

// Validation in setter
try {
  user.age = -5;             // Throws error
} catch (e) {
  console.error(e.message);  // "Invalid age"
}

6. Static Members

class MathUtils {
  // Static property - belongs to class, not instances
  static PI: number = 3.14159;
  
  // Static method - can be called without creating instance
  static calculateCircleArea(radius: number): number {
    return this.PI * radius * radius;
  }
  
  static max(a: number, b: number): number {
    return a > b ? a : b;
  }
}

// Call without creating instance
console.log(MathUtils.PI);                    // 3.14159
console.log(MathUtils.calculateCircleArea(5)); // 78.53975
console.log(MathUtils.max(10, 20));           // 20

// Static members are not available on instances
const utils = new MathUtils();
// console.log(utils.PI); // ❌ Error - static member

// Practical example: Factory pattern
class User {
  constructor(
    public id: number,
    public name: string
  ) {}
  
  // Static factory method
  static create(name: string): User {
    const id = Math.floor(Math.random() * 10000);
    return new User(id, name);
  }
  
  // Static validation
  static isValidName(name: string): boolean {
    return name.length > 0 && name.length < 50;
  }
}

const user = User.create("John");
console.log(User.isValidName("Jane")); // true

7. Abstract Classes

// Abstract class - cannot be instantiated directly
abstract class Shape {
  constructor(public color: string) {}
  
  // Abstract method - must be implemented by subclasses
  abstract area(): number;
  abstract perimeter(): number;
  
  // Concrete method - inherited by subclasses
  describe(): string {
    return `A ${this.color} shape with area ${this.area()}`;
  }
}

// const shape = new Shape("red"); // ❌ Error - cannot instantiate abstract class

// Concrete class must implement abstract methods
class Circle extends Shape {
  constructor(
    color: string,
    public radius: number
  ) {
    super(color);
  }
  
  area(): number {
    return Math.PI * this.radius ** 2;
  }
  
  perimeter(): number {
    return 2 * Math.PI * this.radius;
  }
}

class Rectangle extends Shape {
  constructor(
    color: string,
    public width: number,
    public height: number
  ) {
    super(color);
  }
  
  area(): number {
    return this.width * this.height;
  }
  
  perimeter(): number {
    return 2 * (this.width + this.height);
  }
}

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

const rectangle = new Rectangle("blue", 10, 5);
console.log(rectangle.area());    // 50

When to use abstract classes:

  • Define common behavior with some concrete implementation
  • Force subclasses to implement specific methods
  • Share code between related classes
  • Create base classes for frameworks/libraries

8. Inheritance - extends

// Base class
class Animal {
  constructor(public name: string) {}
  
  move(distance: number): void {
    console.log(`${this.name} moved ${distance}m`);
  }
  
  makeSound(): void {
    console.log("Some generic sound");
  }
}

// Derived class
class Dog extends Animal {
  constructor(name: string, public breed: string) {
    super(name); // Must call super() before accessing 'this'
  }
  
  // Override method
  makeSound(): void {
    console.log("Woof! Woof!");
  }
  
  // New method specific to Dog
  fetch(): void {
    console.log(`${this.name} is fetching`);
  }
}

class Cat extends Animal {
  constructor(name: string) {
    super(name);
  }
  
  // Override method
  makeSound(): void {
    console.log("Meow!");
  }
  
  // Call parent method with super
  move(distance: number): void {
    console.log("Sneaking...");
    super.move(distance); // Call parent implementation
  }
}

const dog = new Dog("Buddy", "Labrador");
dog.makeSound();  // "Woof! Woof!" (overridden)
dog.move(10);     // "Buddy moved 10m" (inherited)
dog.fetch();      // "Buddy is fetching" (new method)

const cat = new Cat("Whiskers");
cat.makeSound();  // "Meow!" (overridden)
cat.move(5);      // "Sneaking..." then "Whiskers moved 5m"

// Type compatibility
const animals: Animal[] = [dog, cat];
animals.forEach(animal => animal.makeSound());

9. Implements - Interface Implementation

// Interface defines contract
interface Drawable {
  draw(): void;
  color: string;
}

interface Resizable {
  resize(scale: number): void;
}

// Class implements single interface
class Circle implements Drawable {
  constructor(
    public color: string,
    public radius: number
  ) {}
  
  draw(): void {
    console.log(`Drawing ${this.color} circle with radius ${this.radius}`);
  }
}

// Class implements multiple interfaces
class Rectangle implements Drawable, Resizable {
  constructor(
    public color: string,
    public width: number,
    public height: number
  ) {}
  
  draw(): void {
    console.log(`Drawing ${this.color} rectangle ${this.width}x${this.height}`);
  }
  
  resize(scale: number): void {
    this.width *= scale;
    this.height *= scale;
  }
}

const rect = new Rectangle("blue", 10, 5);
rect.draw();       // "Drawing blue rectangle 10x5"
rect.resize(2);
rect.draw();       // "Drawing blue rectangle 20x10"

// Interface as type
function renderShape(shape: Drawable): void {
  shape.draw();
}

renderShape(new Circle("red", 5));
renderShape(rect);

Difference between extends and implements:

  • extends - inherits implementation from parent class
  • implements - must provide implementation for interface contract

10. Private Fields with # (ES2022)

// TypeScript 'private' - compile-time only
class User1 {
  private password: string;
  
  constructor(password: string) {
    this.password = password;
  }
}

// At runtime, password is still accessible (JavaScript has no private)
const user1 = new User1("secret");
console.log((user1 as any).password); // "secret" - can access at runtime!

// ECMAScript private fields with # - runtime privacy
class User2 {
  #password: string;
  
  constructor(password: string) {
    this.#password = password;
  }
  
  verifyPassword(input: string): boolean {
    return this.#password === input;
  }
}

const user2 = new User2("secret");
// console.log(user2.#password); // ❌ Syntax error - truly private
console.log((user2 as any).password); // undefined - not accessible

// Private methods with #
class BankAccount {
  #balance: number = 0;
  
  deposit(amount: number): void {
    if (this.#isValidAmount(amount)) {
      this.#balance += amount;
    }
  }
  
  #isValidAmount(amount: number): boolean {
    return amount > 0;
  }
  
  getBalance(): number {
    return this.#balance;
  }
}

When to use which:

  • private keyword - TypeScript compile-time checking, better for most cases
  • # private fields - True runtime privacy, use when security matters

11. this Type in Classes

// 'this' as return type for method chaining (fluent interface)
class Calculator {
  private value: number = 0;
  
  add(n: number): this {
    this.value += n;
    return this;
  }
  
  subtract(n: number): this {
    this.value -= n;
    return this;
  }
  
  multiply(n: number): this {
    this.value *= n;
    return this;
  }
  
  getResult(): number {
    return this.value;
  }
}

const result = new Calculator()
  .add(10)
  .multiply(2)
  .subtract(5)
  .getResult();

console.log(result); // 15

// 'this' type preserves subclass type
class ScientificCalculator extends Calculator {
  power(n: number): this {
    const value = this.getResult();
    // Implementation
    return this;
  }
}

const calc = new ScientificCalculator()
  .add(5)
  .power(2)    // Returns ScientificCalculator, not Calculator
  .multiply(2);

// this parameter type
class Box {
  content: string = "";
  
  // Ensure method is called with correct 'this'
  set(this: Box, value: string): void {
    this.content = value;
  }
}

const box = new Box();
box.set("hello");           // ✅ OK

const set = box.set;
// set("hello");            // ❌ Error - 'this' context lost

12. Generic Classes

// Generic class for type-safe data structures
class Container<T> {
  private items: T[] = [];
  
  add(item: T): void {
    this.items.push(item);
  }
  
  get(index: number): T | undefined {
    return this.items[index];
  }
  
  getAll(): T[] {
    return [...this.items];
  }
  
  filter(predicate: (item: T) => boolean): T[] {
    return this.items.filter(predicate);
  }
}

// Type-safe for numbers
const numbers = new Container<number>();
numbers.add(1);
numbers.add(2);
// numbers.add("3"); // ❌ Error - expects number

// Type-safe for strings
const strings = new Container<string>();
strings.add("hello");
strings.add("world");

// Generic with constraints
class Repository<T extends { id: string | number }> {
  private items: Map<string | number, T> = new Map();
  
  save(item: T): void {
    this.items.set(item.id, item);
  }
  
  findById(id: string | number): T | undefined {
    return this.items.get(id);
  }
  
  findAll(): T[] {
    return Array.from(this.items.values());
  }
}

interface User {
  id: number;
  name: string;
}

const userRepo = new Repository<User>();
userRepo.save({ id: 1, name: "John" });
const user = userRepo.findById(1);

// Multiple type parameters
class Pair<K, V> {
  constructor(
    public key: K,
    public value: V
  ) {}
  
  swap(): Pair<V, K> {
    return new Pair(this.value, this.key);
  }
}

const pair = new Pair<string, number>("age", 30);
const swapped = pair.swap(); // Pair<number, string>

Practice Part (35 min)

Exercise 1: E-commerce Shopping Cart System (15 min)

Build a type-safe shopping cart system with classes.

// TODO: Implement the shopping cart system

// 1. Product class
//    - Properties: id (readonly), name, price, category
//    - Method: getDiscountedPrice(discount: number): number
class Product {
  // TODO: Implement
}

// 2. CartItem class
//    - Properties: product (readonly), quantity (private with getter/setter)
//    - Validate quantity > 0 in setter
//    - Method: getTotalPrice(): number
class CartItem {
  // TODO: Implement
}

// 3. ShoppingCart class
//    - Private items: CartItem[]
//    - Methods:
//      - addItem(product: Product, quantity: number): this
//      - removeItem(productId: string): this
//      - updateQuantity(productId: string, quantity: number): this
//      - getTotalPrice(): number
//      - getItemCount(): number
//      - clear(): this
class ShoppingCart {
  // TODO: Implement
}

// 4. DiscountCart extends ShoppingCart
//    - Add discount functionality
//    - Override getTotalPrice to apply discount
class DiscountCart extends ShoppingCart {
  // TODO: Implement with discount rate
}

// Tests - uncomment after implementation
// const laptop = new Product("1", "Laptop", 999, "Electronics");
// const mouse = new Product("2", "Mouse", 25, "Electronics");

// const cart = new ShoppingCart();
// cart
//   .addItem(laptop, 1)
//   .addItem(mouse, 2);

// console.log(cart.getTotalPrice());  // 1049
// console.log(cart.getItemCount());   // 2

// cart.updateQuantity("2", 3);
// console.log(cart.getTotalPrice());  // 1074

// const discountCart = new DiscountCart(0.1); // 10% discount
// discountCart.addItem(laptop, 1);
// console.log(discountCart.getTotalPrice()); // 899.1

Solution:

Click to see solution
class Product {
  constructor(
    readonly id: string,
    public name: string,
    public price: number,
    public category: string
  ) {}
  
  getDiscountedPrice(discount: number): number {
    return this.price * (1 - discount);
  }
}

class CartItem {
  private _quantity: number;
  
  constructor(
    readonly product: Product,
    quantity: number
  ) {
    this._quantity = quantity;
  }
  
  get quantity(): number {
    return this._quantity;
  }
  
  set quantity(value: number) {
    if (value <= 0) {
      throw new Error("Quantity must be greater than 0");
    }
    this._quantity = value;
  }
  
  getTotalPrice(): number {
    return this.product.price * this._quantity;
  }
}

class ShoppingCart {
  private items: CartItem[] = [];
  
  addItem(product: Product, quantity: number): this {
    const existingItem = this.items.find(item => item.product.id === product.id);
    
    if (existingItem) {
      existingItem.quantity += quantity;
    } else {
      this.items.push(new CartItem(product, quantity));
    }
    
    return this;
  }
  
  removeItem(productId: string): this {
    this.items = this.items.filter(item => item.product.id !== productId);
    return this;
  }
  
  updateQuantity(productId: string, quantity: number): this {
    const item = this.items.find(item => item.product.id === productId);
    
    if (item) {
      item.quantity = quantity;
    }
    
    return this;
  }
  
  getTotalPrice(): number {
    return this.items.reduce((total, item) => total + item.getTotalPrice(), 0);
  }
  
  getItemCount(): number {
    return this.items.reduce((count, item) => count + item.quantity, 0);
  }
  
  clear(): this {
    this.items = [];
    return this;
  }
  
  protected getItems(): CartItem[] {
    return this.items;
  }
}

class DiscountCart extends ShoppingCart {
  constructor(private discountRate: number) {
    super();
  }
  
  getTotalPrice(): number {
    const originalTotal = super.getTotalPrice();
    return originalTotal * (1 - this.discountRate);
  }
  
  setDiscount(rate: number): this {
    this.discountRate = rate;
    return this;
  }
}

// Tests
const laptop = new Product("1", "Laptop", 999, "Electronics");
const mouse = new Product("2", "Mouse", 25, "Electronics");

const cart = new ShoppingCart();
cart
  .addItem(laptop, 1)
  .addItem(mouse, 2);

console.log(cart.getTotalPrice());  // 1049
console.log(cart.getItemCount());   // 3

cart.updateQuantity("2", 3);
console.log(cart.getTotalPrice());  // 1074

const discountCart = new DiscountCart(0.1);
discountCart.addItem(laptop, 1);
console.log(discountCart.getTotalPrice()); // 899.1

Exercise 2: Abstract Shape Hierarchy (12 min)

Implement a shape hierarchy using abstract classes and inheritance.

// TODO: Create abstract shape hierarchy

// 1. Abstract Shape class
//    - Properties: color (protected)
//    - Abstract methods: area(), perimeter()
//    - Concrete method: describe(): string
abstract class Shape {
  // TODO: Implement
}

// 2. Circle extends Shape
//    - Property: radius
//    - Implement area and perimeter
class Circle extends Shape {
  // TODO: Implement
}

// 3. Rectangle extends Shape
//    - Properties: width, height
//    - Implement area and perimeter
class Rectangle extends Shape {
  // TODO: Implement
}

// 4. Triangle extends Shape
//    - Properties: a, b, c (sides)
//    - Implement area (use Heron's formula) and perimeter
class Triangle extends Shape {
  // TODO: Implement
}

// 5. ShapeCalculator class
//    - Static method: totalArea(shapes: Shape[]): number
//    - Static method: largestShape(shapes: Shape[]): Shape
class ShapeCalculator {
  // TODO: Implement static methods
}

// Tests - uncomment after implementation
// const shapes: Shape[] = [
//   new Circle("red", 5),
//   new Rectangle("blue", 10, 5),
//   new Triangle("green", 3, 4, 5)
// ];

// shapes.forEach(shape => console.log(shape.describe()));

// console.log("Total area:", ShapeCalculator.totalArea(shapes));
// console.log("Largest:", ShapeCalculator.largestShape(shapes).describe());

Solution:

Click to see solution
abstract class Shape {
  constructor(protected color: string) {}
  
  abstract area(): number;
  abstract perimeter(): number;
  
  describe(): string {
    return `A ${this.color} ${this.constructor.name} with area ${this.area().toFixed(2)} and perimeter ${this.perimeter().toFixed(2)}`;
  }
}

class Circle extends Shape {
  constructor(color: string, public radius: number) {
    super(color);
  }
  
  area(): number {
    return Math.PI * this.radius ** 2;
  }
  
  perimeter(): number {
    return 2 * Math.PI * this.radius;
  }
}

class Rectangle extends Shape {
  constructor(
    color: string,
    public width: number,
    public height: number
  ) {
    super(color);
  }
  
  area(): number {
    return this.width * this.height;
  }
  
  perimeter(): number {
    return 2 * (this.width + this.height);
  }
}

class Triangle extends Shape {
  constructor(
    color: string,
    public a: number,
    public b: number,
    public c: number
  ) {
    super(color);
  }
  
  area(): number {
    // Heron's formula
    const s = this.perimeter() / 2;
    return Math.sqrt(s * (s - this.a) * (s - this.b) * (s - this.c));
  }
  
  perimeter(): number {
    return this.a + this.b + this.c;
  }
}

class ShapeCalculator {
  static totalArea(shapes: Shape[]): number {
    return shapes.reduce((total, shape) => total + shape.area(), 0);
  }
  
  static largestShape(shapes: Shape[]): Shape {
    return shapes.reduce((largest, current) => 
      current.area() > largest.area() ? current : largest
    );
  }
}

// Tests
const shapes: Shape[] = [
  new Circle("red", 5),
  new Rectangle("blue", 10, 5),
  new Triangle("green", 3, 4, 5)
];

shapes.forEach(shape => console.log(shape.describe()));
// A red Circle with area 78.54 and perimeter 31.42
// A blue Rectangle with area 50.00 and perimeter 30.00
// A green Triangle with area 6.00 and perimeter 12.00

console.log("Total area:", ShapeCalculator.totalArea(shapes).toFixed(2));
// Total area: 134.54

console.log("Largest:", ShapeCalculator.largestShape(shapes).describe());
// Largest: A red Circle with area 78.54 and perimeter 31.42

Exercise 3: Generic Repository Pattern (8 min)

Implement a generic repository for data access.

// TODO: Create generic repository pattern

// 1. Entity interface - all entities must have id
interface Entity {
  id: string | number;
}

// 2. Generic Repository class
//    - Generic type T extends Entity
//    - Private storage: Map<string | number, T>
//    - Methods:
//      - save(entity: T): void
//      - findById(id: string | number): T | undefined
//      - findAll(): T[]
//      - update(id: string | number, updates: Partial<T>): T | undefined
//      - delete(id: string | number): boolean
class Repository<T extends Entity> {
  // TODO: Implement
}

// 3. Specific repositories extending Repository
interface User extends Entity {
  id: number;
  name: string;
  email: string;
}

interface Product extends Entity {
  id: string;
  name: string;
  price: number;
  inStock: boolean;
}

// UserRepository with additional methods
class UserRepository extends Repository<User> {
  findByEmail(email: string): User | undefined {
    // TODO: Implement
  }
}

// ProductRepository with additional methods
class ProductRepository extends Repository<Product> {
  findInStock(): Product[] {
    // TODO: Implement
  }
  
  updatePrice(id: string, newPrice: number): Product | undefined {
    // TODO: Implement
  }
}

// Tests - uncomment after implementation
// const userRepo = new UserRepository();
// userRepo.save({ id: 1, name: "John", email: "john@example.com" });
// userRepo.save({ id: 2, name: "Jane", email: "jane@example.com" });

// console.log(userRepo.findById(1));
// console.log(userRepo.findByEmail("jane@example.com"));

// const productRepo = new ProductRepository();
// productRepo.save({ id: "P1", name: "Laptop", price: 999, inStock: true });
// productRepo.save({ id: "P2", name: "Mouse", price: 25, inStock: false });

// console.log(productRepo.findInStock());
// productRepo.updatePrice("P1", 899);

Solution:

Click to see solution
interface Entity {
  id: string | number;
}

class Repository<T extends Entity> {
  protected storage: Map<string | number, T> = new Map();
  
  save(entity: T): void {
    this.storage.set(entity.id, entity);
  }
  
  findById(id: string | number): T | undefined {
    return this.storage.get(id);
  }
  
  findAll(): T[] {
    return Array.from(this.storage.values());
  }
  
  update(id: string | number, updates: Partial<T>): T | undefined {
    const entity = this.storage.get(id);
    
    if (entity) {
      const updated = { ...entity, ...updates };
      this.storage.set(id, updated);
      return updated;
    }
    
    return undefined;
  }
  
  delete(id: string | number): boolean {
    return this.storage.delete(id);
  }
}

interface User extends Entity {
  id: number;
  name: string;
  email: string;
}

interface Product extends Entity {
  id: string;
  name: string;
  price: number;
  inStock: boolean;
}

class UserRepository extends Repository<User> {
  findByEmail(email: string): User | undefined {
    return this.findAll().find(user => user.email === email);
  }
}

class ProductRepository extends Repository<Product> {
  findInStock(): Product[] {
    return this.findAll().filter(product => product.inStock);
  }
  
  updatePrice(id: string, newPrice: number): Product | undefined {
    return this.update(id, { price: newPrice });
  }
}

// Tests
const userRepo = new UserRepository();
userRepo.save({ id: 1, name: "John", email: "john@example.com" });
userRepo.save({ id: 2, name: "Jane", email: "jane@example.com" });

console.log(userRepo.findById(1));
// { id: 1, name: "John", email: "john@example.com" }

console.log(userRepo.findByEmail("jane@example.com"));
// { id: 2, name: "Jane", email: "jane@example.com" }

const productRepo = new ProductRepository();
productRepo.save({ id: "P1", name: "Laptop", price: 999, inStock: true });
productRepo.save({ id: "P2", name: "Mouse", price: 25, inStock: false });

console.log(productRepo.findInStock());
// [{ id: "P1", name: "Laptop", price: 999, inStock: true }]

productRepo.updatePrice("P1", 899);
console.log(productRepo.findById("P1"));
// { id: "P1", name: "Laptop", price: 899, inStock: true }

Quick Review - Key Concepts

1. What’s the difference between public, private, and protected?

Answer

Access modifiers control visibility of class members:

public - accessible everywhere (default)

class User {
  public name: string; // Accessible anywhere
  
  constructor(name: string) {
    this.name = name;
  }
}

const user = new User("John");
console.log(user.name); // ✅ OK

private - accessible only within the class

class BankAccount {
  private balance: number = 0;
  
  deposit(amount: number): void {
    this.balance += amount; // ✅ OK - within class
  }
  
  getBalance(): number {
    return this.balance; // ✅ OK - within class
  }
}

const account = new BankAccount();
// account.balance; // ❌ Error - private
account.deposit(100); // ✅ OK

protected - accessible within the class and subclasses

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

class Dog extends Animal {
  bark(): void {
    console.log(`${this.name} says woof`); // ✅ OK - in subclass
  }
}

const dog = new Dog("Buddy");
// dog.name; // ❌ Error - protected
dog.bark(); // ✅ OK

When to use:

  • public - API methods, properties users should access
  • private - internal implementation details, sensitive data
  • protected - shared between parent and child classes

Note: TypeScript private is compile-time only. Use # for runtime privacy.

2. What are abstract classes and when to use them?

Answer

Abstract classes cannot be instantiated and may contain abstract methods that subclasses must implement.

Syntax:

abstract class Shape {
  constructor(public color: string) {}
  
  // Abstract method - no implementation
  abstract area(): number;
  
  // Concrete method - has implementation
  describe(): string {
    return `A ${this.color} shape`;
  }
}

// Cannot instantiate
// const shape = new Shape("red"); // ❌ Error

// Must implement abstract methods
class Circle extends Shape {
  constructor(color: string, public radius: number) {
    super(color);
  }
  
  area(): number {
    return Math.PI * this.radius ** 2;
  }
}

When to use abstract classes:

  1. Define common behavior with some implementation:
abstract class HttpClient {
  abstract request(url: string): Promise<Response>;
  
  // Shared implementation
  async get(url: string): Promise<Response> {
    return this.request(url);
  }
  
  async post(url: string, body: any): Promise<Response> {
    return this.request(url);
  }
}
  1. Force subclasses to implement specific methods:
abstract class DataProcessor {
  abstract validate(data: unknown): boolean;
  abstract transform(data: unknown): any;
  
  process(data: unknown): any {
    if (this.validate(data)) {
      return this.transform(data);
    }
    throw new Error("Invalid data");
  }
}
  1. Template method pattern:
abstract class Game {
  // Template method
  play(): void {
    this.initialize();
    this.startPlay();
    this.endPlay();
  }
  
  abstract initialize(): void;
  abstract startPlay(): void;
  abstract endPlay(): void;
}

Abstract class vs Interface:

  • Abstract class: Can have implementation, single inheritance
  • Interface: No implementation, multiple inheritance (implements)

3. How do getters and setters work?

Answer

Getters and setters allow controlled access to properties, accessed like properties but executed as methods.

Basic syntax:

class User {
  private _age: number;
  
  constructor(age: number) {
    this._age = age;
  }
  
  // Getter
  get age(): number {
    return this._age;
  }
  
  // Setter
  set age(value: number) {
    if (value < 0 || value > 150) {
      throw new Error("Invalid age");
    }
    this._age = value;
  }
}

const user = new User(30);
console.log(user.age);  // Calls getter, returns 30
user.age = 31;          // Calls setter

Common use cases:

  1. Validation:
class Product {
  private _price: number;
  
  set price(value: number) {
    if (value < 0) {
      throw new Error("Price cannot be negative");
    }
    this._price = value;
  }
  
  get price(): number {
    return this._price;
  }
}
  1. Computed properties:
class Rectangle {
  constructor(
    public width: number,
    public height: number
  ) {}
  
  get area(): number {
    return this.width * this.height;
  }
}

const rect = new Rectangle(10, 5);
console.log(rect.area); // 50 (computed)
  1. Lazy initialization:
class Database {
  private _connection?: Connection;
  
  get connection(): Connection {
    if (!this._connection) {
      this._connection = createConnection();
    }
    return this._connection;
  }
}
  1. Encapsulation:
class BankAccount {
  private _balance: number = 0;
  
  get balance(): number {
    return this._balance; // Read-only from outside
  }
  
  deposit(amount: number): void {
    this._balance += amount;
  }
}

Benefits:

  • Validation on assignment
  • Computed values
  • Encapsulation (hide internal state)
  • API compatibility (can add logic later)

4. What’s the difference between extends and implements?

Answer

extends - inherits implementation from a class

implements - provides implementation for an interface

extends (inheritance):

class Animal {
  constructor(public name: string) {}
  
  move(): void {
    console.log(`${this.name} is moving`);
  }
}

class Dog extends Animal {
  bark(): void {
    console.log("Woof!");
  }
}

const dog = new Dog("Buddy");
dog.move(); // ✅ Inherited from Animal
dog.bark(); // ✅ Own method

implements (interface contract):

interface Drawable {
  draw(): void;
  color: string;
}

class Circle implements Drawable {
  constructor(public color: string) {}
  
  // Must implement all interface members
  draw(): void {
    console.log(`Drawing ${this.color} circle`);
  }
}

Key differences:

Featureextendsimplements
Inherits codeYesNo
MultipleNo (single)Yes (multiple interfaces)
Must implementOnly abstractEverything
Access to parentsuper keywordNo

Can combine both:

abstract class Shape {
  constructor(public color: string) {}
  abstract area(): number;
}

interface Drawable {
  draw(): void;
}

class Circle extends Shape implements Drawable {
  constructor(color: string, public radius: number) {
    super(color);
  }
  
  area(): number {
    return Math.PI * this.radius ** 2;
  }
  
  draw(): void {
    console.log(`Drawing circle`);
  }
}

When to use:

  • extends - code reuse, is-a relationship
  • implements - contract enforcement, can-do relationship

5. How do generic classes work?

Answer

Generic classes use type parameters to create reusable, type-safe components.

Basic syntax:

class Box<T> {
  private value: T;
  
  constructor(value: T) {
    this.value = value;
  }
  
  getValue(): T {
    return this.value;
  }
  
  setValue(value: T): void {
    this.value = value;
  }
}

const numberBox = new Box<number>(123);
const stringBox = new Box<string>("hello");

numberBox.setValue(456);        // ✅ OK
// numberBox.setValue("text");  // ❌ Error

With constraints:

interface HasId {
  id: string | number;
}

class Repository<T extends HasId> {
  private items: Map<string | number, T> = new Map();
  
  save(item: T): void {
    this.items.set(item.id, item); // ✅ item.id guaranteed to exist
  }
  
  findById(id: string | number): T | undefined {
    return this.items.get(id);
  }
}

interface User {
  id: number;
  name: string;
}

const userRepo = new Repository<User>();

Multiple type parameters:

class Pair<K, V> {
  constructor(
    public key: K,
    public value: V
  ) {}
  
  swap(): Pair<V, K> {
    return new Pair(this.value, this.key);
  }
}

const pair = new Pair<string, number>("age", 30);
const swapped = pair.swap(); // Pair<number, string>

Generic methods in classes:

class Utils {
  static map<T, U>(array: T[], fn: (item: T) => U): U[] {
    return array.map(fn);
  }
}

const numbers = [1, 2, 3];
const strings = Utils.map(numbers, n => n.toString()); // string[]

Common patterns:

  1. Collections:
class Stack<T> {
  private items: T[] = [];
  
  push(item: T): void {
    this.items.push(item);
  }
  
  pop(): T | undefined {
    return this.items.pop();
  }
}
  1. Data access:
class Cache<K, V> {
  private storage = new Map<K, V>();
  
  get(key: K): V | undefined {
    return this.storage.get(key);
  }
  
  set(key: K, value: V): void {
    this.storage.set(key, value);
  }
}

Benefits:

  • Type safety without code duplication
  • Reusable across different types
  • Compile-time type checking
  • Better IDE support

Checklist - What You Should Know After Day 7

  • Create classes with properties and methods
  • Use access modifiers (public, private, protected)
  • Write parameter properties for concise constructors
  • Implement readonly properties
  • Create getters and setters
  • Use static members and methods
  • Define and extend abstract classes
  • Implement inheritance with extends
  • Implement interfaces with implements
  • Create generic classes with type parameters

Quick Reference Card

// Basic class
class User {
  constructor(
    public id: number,
    public name: string,
    private password: string
  ) {}
  
  greet(): string {
    return `Hello, ${this.name}`;
  }
}

// Access modifiers
public name: string;     // Accessible everywhere
private password: string; // Only in this class
protected role: string;   // This class and subclasses

// Readonly
readonly id: number;

// Getters/Setters
get age(): number { return this._age; }
set age(value: number) { this._age = value; }

// Static
static create(): User { return new User(); }

// Abstract class
abstract class Shape {
  abstract area(): number;
  
  describe(): string {
    return `Area: ${this.area()}`;
  }
}

// Inheritance
class Circle extends Shape {
  area(): number {
    return Math.PI * this.radius ** 2;
  }
}

// Implements
class MyClass implements Interface1, Interface2 {
  // Must implement all interface members
}

// Generic class
class Box<T> {
  constructor(private value: T) {}
  
  getValue(): T {
    return this.value;
  }
}

// Private fields (runtime)
class User {
  #password: string;
  
  constructor(password: string) {
    this.#password = password;
  }
}

Tomorrow: Design Patterns in TypeScript - Singleton, Factory, Observer, and more

Back to blog