Skip to main content
TypeScript intermediate Lesson 8 of 21

Classes in TypeScript

Use access modifiers, abstract classes, parameter properties, and interface implementation to write well-structured object-oriented TypeScript.

Class Basics

TypeScript classes build on ES2015 classes and add type annotations for properties and methods. The key benefit over plain JavaScript classes is that TypeScript verifies all property accesses and method calls — mistyped property names, wrong argument types, and missing fields are all caught before runtime.

class Animal {
  name: string; // property declaration with type

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

  speak(): string {
    return `${this.name} makes a sound.`;
  }
}

const dog = new Animal("Rex");
dog.speak(); // "Rex makes a sound."
dog.color;   // Error: Property 'color' does not exist on type 'Animal'

Access Modifiers

Access modifiers control where class members can be read and written. They enforce encapsulation at the type level — hiding implementation details and making the public API of a class explicit. TypeScript checks these at compile time.

public (the default) — accessible from anywhere:

class User {
  public name: string;  // same as just: name: string
}

private — accessible only within the class body. External code cannot read or write the field, protecting internal state:

class BankAccount {
  private balance: number; // hidden from outside — only accessible via methods

  constructor(initialBalance: number) {
    this.balance = initialBalance;
  }

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

  getBalance(): number {
    return this.balance; // controlled read-only access
  }
}

const account = new BankAccount(100);
account.deposit(50);
account.balance; // Error: Property 'balance' is private

protected — accessible within the class and any subclass, but not from outside the hierarchy. Useful for sharing implementation details with child classes without exposing them publicly:

class Vehicle {
  protected speed: number = 0; // subclasses can read and write this

  accelerate(amount: number): void {
    this.speed += amount;
  }
}

class Car extends Vehicle {
  describe(): string {
    return `Speed: ${this.speed}`; // fine — Car is a subclass of Vehicle
  }
}

const car = new Car();
car.speed; // Error — protected, not accessible from outside the class hierarchy

ECMAScript Private Fields

TypeScript’s private modifier is erased at compile time — the property is still accessible in the JavaScript output. For truly runtime-enforced privacy, use the ECMAScript # syntax. This matters when you’re writing libraries or dealing with code that might bypass TypeScript’s checks.

class Counter {
  #count = 0; // true runtime privacy — enforced by the JS engine, not just TypeScript

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

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

const c = new Counter();
c.increment();
c.value;   // 1
c.#count;  // SyntaxError at runtime — not just a TypeScript error

Prefer # for sensitive data where runtime guarantees matter. Use TypeScript’s private for internal organization where compile-time checking is sufficient.

Parameter Properties

Declaring a class property, assigning it in the constructor body, and typing it all require repetitive boilerplate. Parameter properties eliminate this by letting you declare and initialize fields directly in the constructor signature, making simple data classes much more concise.

// Without parameter properties — lots of repetition
class Point {
  x: number;
  y: number;

  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
}

// With parameter properties — same result, much less code
class Point {
  constructor(
    public x: number,
    public y: number
  ) {}
}

Works with any access modifier or readonly:

class User {
  constructor(
    public readonly id: number,   // public and immutable after construction
    public name: string,           // public and mutable
    private email: string          // private, hidden from outside
  ) {}
}

const user = new User(1, "Alice", "alice@example.com");
user.id = 99;  // Error: Cannot assign to 'id' because it is a read-only property

Readonly Properties

Readonly properties can only be set during construction. They communicate that a value is fixed for the lifetime of the object — useful for configuration objects, value types, and any class where immutability is important.

class Config {
  readonly maxRetries: number;
  readonly baseUrl: string;

  constructor(maxRetries: number, baseUrl: string) {
    // Can be set in the constructor
    this.maxRetries = maxRetries;
    this.baseUrl = baseUrl;
  }
  // After construction, these fields can never change
}

Getters and Setters

Getters and setters let you expose a property-like API while running validation or transformation logic behind the scenes. They’re the right choice when you need to compute a derived value or enforce constraints on assignment.

class Temperature {
  private _celsius: number;

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

  get celsius(): number {
    return this._celsius;
  }

  set celsius(value: number) {
    // Validate before accepting the new value
    if (value < -273.15) throw new RangeError("Below absolute zero");
    this._celsius = value;
  }

  // Derived value — computed from the stored celsius, no separate field needed
  get fahrenheit(): number {
    return this._celsius * 9/5 + 32;
  }
}

const t = new Temperature(100);
t.fahrenheit; // 212
t.celsius = -300; // RangeError — the setter enforces the constraint

Implementing Interfaces

A class can declare that it implements one or more interfaces with the implements keyword. TypeScript then verifies that every method and property in the interface is present and correctly typed. This is a powerful way to enforce contracts and make classes interchangeable with each other.

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

interface Loggable {
  log(): void;
}

// TypeScript errors immediately if any required method is missing or wrongly typed
class UserRecord implements Serializable, Loggable {
  constructor(private id: number, private name: string) {}

  serialize(): string {
    return JSON.stringify({ id: this.id, name: this.name });
  }

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

  log(): void {
    console.log(`User ${this.id}: ${this.name}`);
  }
}

Abstract Classes

Abstract classes define a blueprint that concrete subclasses must complete. Unlike interfaces, they can contain real method implementations that subclasses inherit. You can’t instantiate an abstract class directly — it’s a guarantee that the class is only ever used as a base.

This is the right tool when you have shared behavior to provide but also have operations that must be customized per subclass.

abstract class Shape {
  // Subclasses must implement these — no default implementation possible
  abstract area(): number;
  abstract perimeter(): number;

  // Shared behavior all shapes get for free
  describe(): string {
    return `Area: ${this.area().toFixed(2)}, Perimeter: ${this.perimeter().toFixed(2)}`;
  }
}

class Circle extends Shape {
  constructor(private radius: number) {
    super();
  }

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

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

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

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

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

// Both share the describe() method from Shape
const shapes: Shape[] = [new Circle(5), new Rectangle(4, 6)];
shapes.forEach((s) => console.log(s.describe()));

new Shape(); // Error: Cannot create an instance of an abstract class

Static Members

Static properties and methods belong to the class constructor itself, not to instances. They’re useful for utility functions, constants, and factory methods that don’t need access to instance data.

class MathUtils {
  static readonly PI = 3.14159265358979;

  static circleArea(radius: number): number {
    return MathUtils.PI * radius ** 2;
  }

  static clamp(value: number, min: number, max: number): number {
    return Math.min(Math.max(value, min), max);
  }
}

// Called on the class, not on an instance
MathUtils.circleArea(5);       // 78.539...
MathUtils.clamp(150, 0, 100);  // 100

Class as Type

In TypeScript, a class declaration creates two things simultaneously: a value (the constructor function) and a type (the shape of instances). Understanding the difference lets you type both instances and constructors precisely.

class Dog {
  constructor(public name: string) {}
  bark() { return "Woof!"; }
}

// Dog as a type — describes the shape of an instance
function greet(dog: Dog): string {
  return `Hello, ${dog.name}!`;
}

// typeof Dog — describes the constructor itself, for factory patterns
function createDog(Ctor: typeof Dog, name: string): Dog {
  return new Ctor(name);
}

Practical Example: A Service Base Class

This example combines abstract classes, generics, and access modifiers to build a reusable base service. Concrete services inherit the CRUD operations and only need to implement the domain-specific logic.

abstract class BaseService<T extends { id: number }> {
  // Protected so subclasses can access the store for custom queries
  protected items: Map<number, T> = new Map();

  findById(id: number): T | undefined {
    return this.items.get(id);
  }

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

  save(item: T): T {
    this.items.set(item.id, item);
    return item;
  }

  delete(id: number): boolean {
    return this.items.delete(id);
  }

  // Each subclass defines its own validation rules
  abstract validate(item: T): boolean;
}

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

class UserService extends BaseService<User> {
  // Domain-specific validation
  validate(user: User): boolean {
    return user.name.length > 0 && user.email.includes("@");
  }

  // Domain-specific query not in the base class
  findByEmail(email: string): User | undefined {
    return this.findAll().find((u) => u.email === email);
  }
}

const svc = new UserService();
svc.save({ id: 1, name: "Alice", email: "alice@example.com" });
svc.findByEmail("alice@example.com"); // User

Frequently Asked Questions

What access modifiers does TypeScript have?
TypeScript has public (default), private, protected, and readonly. It also supports the ECMAScript # private field syntax for true runtime privacy.
What is the difference between private and #?
TypeScript's private modifier is erased at compile time — the field is accessible in JavaScript. ECMAScript # private fields are enforced at runtime and are truly inaccessible outside the class.
What are abstract classes used for?
Abstract classes define a base structure that subclasses must implement. They can contain both implemented methods and abstract method signatures. You cannot instantiate an abstract class directly.