TypeScript Classes

Classes in TypeScript provide a powerful way to define blueprints for creating objects with properties and methods. They combine the familiar class-based syntax from languages like Java or C# with JavaScript's prototype-based inheritance, all while adding TypeScript's type safety.

Class Basics

Here's a basic class definition in TypeScript:

Basic Class

class Person {
  name: string;
  age: number;

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

  greet(): string {
    return `Hello, my name is ${this.name} and I'm ${this.age} years old.`;
  }
}

// Creating an instance of the class
const alice = new Person("Alice", 28);
console.log(alice.greet()); // "Hello, my name is Alice and I'm 28 years old."

This example shows the fundamental components of a class:

  • Properties (name and age)
  • Constructor method that initializes the properties
  • Instance method (greet())
  • Creating an instance of the class using the new keyword

Access Modifiers

TypeScript provides access modifiers to control the visibility of class members:

Access Modifiers

class BankAccount {
  // Public - accessible from anywhere (default)
  public accountNumber: string;

  // Private - only accessible within the class
  private balance: number;

  // Protected - accessible within the class and its subclasses
  protected owner: string;

  constructor(accountNumber: string, initialBalance: number, owner: string) {
    this.accountNumber = accountNumber;
    this.balance = initialBalance;
    this.owner = owner;
  }

  // Public method
  public deposit(amount: number): void {
    if (amount > 0) {
      this.balance += amount;
      console.log(`Deposited ${amount}. New balance: ${this.balance}`);
    }
  }

  // Public method
  public getBalance(): number {
    return this.balance;
  }
}

const account = new BankAccount("123456", 1000, "Alice");
account.deposit(500); // Deposited 500. New balance: 1500
console.log(account.getBalance()); // 1500
console.log(account.accountNumber); // "123456"

// These would cause compiler errors:
// console.log(account.balance); // Error: Property 'balance' is private
// console.log(account.owner);   // Error: Property 'owner' is protected

Access modifiers help enforce encapsulation:

  • public: Members are accessible from anywhere (default if no modifier is specified)
  • private: Members are only accessible within the class where they're defined
  • protected: Members are accessible within the class and any subclasses

Parameter Properties

TypeScript provides a concise way to define and initialize class members in the constructor:

Parameter Properties

class Person {
  // Parameter properties - shorthand that creates and initializes class members
  constructor(
    public name: string,
    public age: number,
    private ssn: string
  ) {
    // No need to manually assign properties
  }

  greet(): string {
    return `Hello, my name is ${this.name} and I'm ${this.age} years old.`;
  }
}

const bob = new Person("Bob", 32, "123-45-6789");
console.log(bob.name); // "Bob"
console.log(bob.age); // 32
// console.log(bob.ssn);  // Error: Property 'ssn' is private

This syntax reduces boilerplate code by combining the declaration and initialization of class properties.

Readonly Properties

You can use the readonly modifier to make properties that can only be set during initialization:

Readonly Properties

class Circle {
  readonly radius: number;

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

  calculateArea(): number {
    return Math.PI * this.radius * this.radius;
  }
}

const circle = new Circle(5);
console.log(circle.radius); // 5
console.log(circle.calculateArea()); // ~78.54

// This would cause a compiler error:
// circle.radius = 10; // Error: Cannot assign to 'radius' because it is a read-only property

The readonly modifier ensures that a property can't be changed after initialization, providing a level of immutability.

Getters and Setters

TypeScript supports getter and setter methods to control access to class properties:

Getters and Setters

class Temperature {
  private _celsius: number;

  constructor(celsius: number) {
    this._celsius = celsius;
  }

  // Getter for Celsius
  get celsius(): number {
    return this._celsius;
  }

  // Setter for Celsius
  set celsius(value: number) {
    if (value < -273.15) {
      throw new Error("Temperature cannot be below absolute zero");
    }
    this._celsius = value;
  }

  // Getter for Fahrenheit
  get fahrenheit(): number {
    return (this._celsius * 9) / 5 + 32;
  }

  // Setter for Fahrenheit
  set fahrenheit(value: number) {
    this.celsius = ((value - 32) * 5) / 9;
  }
}

const temp = new Temperature(25);
console.log(temp.celsius); // 25
console.log(temp.fahrenheit); // 77

temp.celsius = 30;
console.log(temp.fahrenheit); // 86

temp.fahrenheit = 68;
console.log(temp.celsius); // 20

// This would throw an error
// temp.celsius = -300; // Error: Temperature cannot be below absolute zero

Getters and setters allow you to:

  • Control read and write access to properties
  • Add validation logic when setting values
  • Compute values on the fly
  • Hide internal state while providing a public interface

Static Members

Static members belong to the class itself rather than to instances of the class:

Static Members

class MathHelper {
  // Static property
  static readonly PI = 3.14159265359;

  // Static method
  static square(num: number): number {
    return num * num;
  }

  // Static method using static property
  static calculateCircleArea(radius: number): number {
    return MathHelper.PI * MathHelper.square(radius);
  }
}

// Access static members without creating an instance
console.log(MathHelper.PI); // 3.14159265359
console.log(MathHelper.square(4)); // 16
console.log(MathHelper.calculateCircleArea(5)); // ~78.54

Static members are useful for utility functions and constants that are related to the class but don't depend on instance-specific data.

Inheritance

TypeScript supports class inheritance, allowing you to extend existing classes:

Class Inheritance

// Base class
class Animal {
  constructor(public name: string) {}

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

// Derived class
class Dog extends Animal {
  constructor(
    name: string,
    private breed: string
  ) {
    // Call the base class constructor
    super(name);
  }

  // Override the move method
  move(distance: number = 5): void {
    console.log(`${this.name} the ${this.breed} is running...`);
    // Call the base class method
    super.move(distance);
  }

  // Add a new method
  bark(): void {
    console.log("Woof! Woof!");
  }
}

const dog = new Dog("Rex", "German Shepherd");
dog.move(); // "Rex the German Shepherd is running..." followed by "Rex moved 5 meters."
dog.bark(); // "Woof! Woof!"

Key points about inheritance:

  • Use the extends keyword to create a subclass
  • Use super() in the constructor to call the parent class constructor
  • Use super.methodName() to call a parent class method
  • Subclasses can override methods from the parent class
  • Subclasses can add new properties and methods

Abstract Classes

Abstract classes serve as base classes that cannot be instantiated directly:

Abstract Classes

abstract class Shape {
  constructor(protected color: string) {}

  // Abstract method (must be implemented by subclasses)
  abstract calculateArea(): number;

  // Regular method
  displayColor(): void {
    console.log(`This shape is ${this.color}`);
  }
}

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

  // Implement the abstract method
  calculateArea(): number {
    return this.width * this.height;
  }
}

// Cannot create an instance of an abstract class
// const shape = new Shape("red"); // Error

const rectangle = new Rectangle("blue", 5, 10);
console.log(rectangle.calculateArea()); // 50
rectangle.displayColor(); // "This shape is blue"

Abstract classes are useful when you want to define a common structure for related classes, but ensure that only concrete subclasses can be instantiated.

Implementing Interfaces

Classes can implement interfaces, ensuring they conform to a specific structure:

Implementing Interfaces

interface Drivable {
  start(): void;
  stop(): void;
  speed: number;
}

class Car implements Drivable {
  speed: number = 0;

  start(): void {
    console.log("Car started. Ready to drive!");
  }

  stop(): void {
    console.log("Car stopped.");
    this.speed = 0;
  }

  accelerate(increment: number): void {
    this.speed += increment;
    console.log(`Car accelerating. Current speed: ${this.speed}`);
  }
}

const myCar = new Car();
myCar.start(); // "Car started. Ready to drive!"
myCar.accelerate(50); // "Car accelerating. Current speed: 50"
myCar.stop(); // "Car stopped."

By implementing an interface, a class guarantees that it provides all the properties and methods defined in the interface. This is a form of contract that helps ensure type safety.

Method Overloading

TypeScript allows method overloading in classes:

Method Overloading

class Calculator {
  // Method overloads
  add(a: number, b: number): number;
  add(a: string, b: string): string;

  // Implementation
  add(a: number | string, b: number | string): number | string {
    if (typeof a === "number" && typeof b === "number") {
      return a + b;
    }
    if (typeof a === "string" && typeof b === "string") {
      return a.concat(b);
    }
    throw new Error("Parameters must be both numbers or both strings");
  }
}

const calc = new Calculator();
console.log(calc.add(5, 3)); // 8
console.log(calc.add("Hello, ", "World")); // "Hello, World"

// This would cause a runtime error due to type checking:
// calc.add(5, "World"); // Error: Parameters must be both numbers or both strings

Method overloading allows you to define multiple signatures for a method, providing more specific type information based on the types of arguments.

Using Generics with Classes

Generics make classes more flexible by allowing them to work with a variety of types:

Generic Classes

class Box<T> {
  private content: T;

  constructor(content: T) {
    this.content = content;
  }

  getContent(): T {
    return this.content;
  }

  setContent(newContent: T): void {
    this.content = newContent;
  }
}

// Box of numbers
const numberBox = new Box<number>(123);
console.log(numberBox.getContent()); // 123

// Box of strings
const stringBox = new Box<string>("Hello TypeScript");
console.log(stringBox.getContent()); // "Hello TypeScript"

// Box of objects
interface Person {
  name: string;
  age: number;
}

const personBox = new Box<Person>({ name: "Alice", age: 28 });
const person = personBox.getContent();
console.log(`${person.name} is ${person.age} years old.`); // "Alice is 28 years old."

Generic classes allow you to create reusable components that work with different types while maintaining type safety.

Best Practices for TypeScript Classes

1. Use PascalCase for class names

// Good
class UserProfile {}

// Not good
class userProfile {}

2. Prefer private members for internal state

// Good
class Counter {
  private count: number = 0;

  increment(): void {
    this.count++;
  }

  getCount(): number {
    return this.count;
  }
}

// Less good - exposes internal state
class Counter {
  public count: number = 0;

  increment(): void {
    this.count++;
  }
}

3. Use parameter properties for concise code

// Good - concise
class Person {
  constructor(
    public name: string,
    private age: number
  ) {}
}

// More verbose
class Person {
  public name: string;
  private age: number;

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

4. Make good use of inheritance and composition

// Inheritance - "is-a" relationship
class Animal {}
class Dog extends Animal {}

// Composition - "has-a" relationship
class Engine {}
class Car {
  private engine: Engine;

  constructor() {
    this.engine = new Engine();
  }
}

5. Implement interfaces for better type safety

interface Repository<T> {
  getById(id: string): T | null;
  save(item: T): void;
}

class UserRepository implements Repository<User> {
  getById(id: string): User | null {
    // Implementation
    return null;
  }

  save(user: User): void {
    // Implementation
  }
}

6. Use method access modifiers consistently

class DataProcessor {
  // Public methods for external API
  public processData(data: any[]): any[] {
    // Process using private methods
    return this.cleanData(this.sortData(data));
  }

  // Private methods for internal use
  private sortData(data: any[]): any[] {
    return [...data].sort();
  }

  private cleanData(data: any[]): any[] {
    return data.filter((item) => item !== null);
  }
}

Real-World Examples

User Authentication

Authentication Example

class User {
  constructor(
    public username: string,
    private password: string,
    public email: string,
    private readonly createdAt: Date = new Date()
  ) {}

  changePassword(oldPassword: string, newPassword: string): boolean {
    if (this.verifyPassword(oldPassword)) {
      this.password = newPassword;
      return true;
    }
    return false;
  }

  private verifyPassword(password: string): boolean {
    return this.password === password; // Simplified - would use hashing in reality
  }

  getAccountInfo(): { username: string; email: string; createdAt: Date } {
    return {
      username: this.username,
      email: this.email,
      createdAt: this.createdAt,
    };
  }
}

class AuthenticationService {
  private users: User[] = [];

  register(username: string, password: string, email: string): User {
    if (this.findUserByUsername(username)) {
      throw new Error("Username already exists");
    }

    const user = new User(username, password, email);
    this.users.push(user);
    return user;
  }

  login(username: string, password: string): User | null {
    const user = this.findUserByUsername(username);
    if (user && user.changePassword(password, password)) {
      // Verifies password
      return user;
    }
    return null;
  }

  private findUserByUsername(username: string): User | undefined {
    return this.users.find((user) => user.username === username);
  }
}

// Usage
const authService = new AuthenticationService();
try {
  authService.register("alice", "securepass", "alice@example.com");
  const user = authService.login("alice", "securepass");
  if (user) {
    console.log("Login successful");
    console.log(user.getAccountInfo());
  } else {
    console.log("Login failed");
  }
} catch (error) {
  console.error(error);
}

Shopping Cart

Shopping Cart Example

class Product {
  constructor(
    public id: string,
    public name: string,
    public price: number
  ) {}
}

class CartItem {
  constructor(
    public product: Product,
    public quantity: number
  ) {}

  get total(): number {
    return this.product.price * this.quantity;
  }
}

class ShoppingCart {
  private items: CartItem[] = [];

  addItem(product: Product, quantity: number = 1): void {
    const existingItem = this.items.find((item) => item.product.id === product.id);

    if (existingItem) {
      existingItem.quantity += quantity;
    } else {
      this.items.push(new CartItem(product, quantity));
    }
  }

  removeItem(productId: string): void {
    this.items = this.items.filter((item) => item.product.id !== productId);
  }

  updateQuantity(productId: string, quantity: number): void {
    const item = this.items.find((item) => item.product.id === productId);

    if (item) {
      if (quantity <= 0) {
        this.removeItem(productId);
      } else {
        item.quantity = quantity;
      }
    }
  }

  getItems(): ReadonlyArray<CartItem> {
    return this.items;
  }

  getTotal(): number {
    return this.items.reduce((total, item) => total + item.total, 0);
  }

  clear(): void {
    this.items = [];
  }
}

// Usage
const laptop = new Product("p1", "Laptop", 999);
const phone = new Product("p2", "Smartphone", 699);
const headphones = new Product("p3", "Headphones", 149);

const cart = new ShoppingCart();
cart.addItem(laptop);
cart.addItem(phone, 2);
cart.addItem(headphones);

console.log("Cart items:", cart.getItems());
console.log("Total:", cart.getTotal()); // 999 + (699 * 2) + 149 = 2546

cart.updateQuantity("p2", 1); // Change phone quantity to 1
console.log("Updated total:", cart.getTotal()); // 999 + 699 + 149 = 1847

cart.removeItem("p3"); // Remove headphones
console.log("Final total:", cart.getTotal()); // 999 + 699 = 1698

Exercises

Exercise 1: Basic Class

Create a Rectangle class with width and height properties and methods to calculate the area and perimeter.

Exercise 2: Class with Private Properties and Getters/Setters

Create a BankAccount class with a private _balance property and getters and setters to control access to it. Include methods for depositing and withdrawing money.

Exercise 3: Inheritance and Polymorphism

Create a base Shape class with a method to calculate area. Then create Circle and Square classes that inherit from Shape and implement the area calculation.

Summary

TypeScript classes combine the familiar syntax of traditional object-oriented programming with the powerful type system of TypeScript, providing a robust way to create well-structured, type-safe applications.

Key concepts covered in this guide:

  • Basic class syntax, properties, and methods
  • Access modifiers: public, private, and protected
  • Parameter properties for concise property declaration
  • readonly properties for immutability
  • Getters and setters to control access to properties
  • Static members that belong to the class rather than instances
  • Inheritance using the extends keyword
  • Abstract classes that serve as base classes
  • Implementing interfaces to ensure type conformance
  • Method overloading for type-specific behavior
  • Using generics to create flexible, reusable classes

By mastering these concepts, you'll be well-equipped to create maintainable, type-safe object-oriented code in TypeScript.