TypeScript · Lesson 6 of 12

Classes, Access Modifiers and Abstract Classes

Write TypeScript classes with typed fields, public, private, protected and readonly modifiers, parameter properties, interfaces and abstract classes.

  • Intermediate
  • 15 min read
  • 3 objectives

Before this lessonLesson 5: Generics and Utility Types

What you will learn

  • Declare typed fields and constructors
  • Choose between private, #private, protected and readonly
  • Implement interfaces and extend abstract classes

Your Progress

0 of 12 lessons 0%

  • Lessons0 / 12
  • Completed0
  • Est. time left~ 3 hours

Create a free account to keep your progress on every device.

Tip: pressing Next marks this lesson complete automatically.

JavaScript has had classes since 2015. TypeScript adds types for their fields and methods, plus a few keywords that control who can read or change what. Classes are not required in TypeScript (many codebases use plain objects and functions), but you will meet them in frameworks like Angular and NestJS, in SDKs, and anywhere state and behaviour belong together.

Fields, constructors and methods

In TypeScript you declare fields at the top of the class with their types. Under strict mode, every field must be definitely assigned: either with an initializer or in the constructor. That rule catches the classic bug of reading a property that was never set.

class Cart {
  items: { sku: string; price: number }[] = [];   // initializer
  owner: string;

  constructor(owner: string) {
    this.owner = owner;                             // assigned in constructor
  }

  add(sku: string, price: number): this {
    this.items.push({ sku, price });
    return this;                                    // returning this enables chaining
  }

  get total(): number {                             // a getter reads like a field
    return this.items.reduce((sum, i) => sum + i.price, 0);
  }
}

const cart = new Cart("Ada").add("pen", 3).add("book", 12);
cart.total;   // 15

Here is the same class in JavaScript, which is exactly what the TypeScript compiles to once types are removed:

class Cart {
  items = [];
  constructor(owner) {
    this.owner = owner;
  }
  add(sku, price) {
    this.items.push({ sku, price });
    return this;
  }
  get total() {
    return this.items.reduce((sum, i) => sum + i.price, 0);
  }
}

const cart = new Cart("Ada").add("pen", 3).add("book", 12);
console.log(cart.owner, cart.items.length, cart.total);
Output
Ada 2 15

Access modifiers: public, private, protected

Members are public by default. Mark implementation details private (only this class can use them) or protected (this class and subclasses). Hiding internals means you can change them later without breaking code that uses the class.

class BankAccount {
  public readonly id: string;
  private balance = 0;
  protected history: number[] = [];

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

  deposit(amount: number) {
    if (amount <= 0) throw new Error("Amount must be positive");
    this.balance += amount;
    this.history.push(amount);
  }

  getBalance() {
    return this.balance;
  }
}

const acct = new BankAccount("acc_1");
acct.deposit(100);
// acct.balance = 1_000_000;  // Error: Property 'balance' is private
// acct.id = "other";         // Error: Cannot assign to 'id' because it is a read-only property

readonly means a field can only be assigned in its declaration or the constructor. It is a good default for IDs and configuration that should never change after creation.

class Counter {
  #count = 0;          // truly private at runtime
  increment() {
    this.#count++;
    return this.#count;
  }
}

const c = new Counter();
c.increment();
console.log(c.increment());
console.log(c.count, Object.keys(c).length);
Output
2
undefined 0

Parameter properties

Declaring a field, taking a constructor parameter and assigning one to the other is so common that TypeScript has a shortcut. Put a modifier (private, public, protected or readonly) on a constructor parameter and it becomes a field automatically.

class EmailService {
  constructor(
    private readonly apiKey: string,
    public readonly from = "noreply@stackcone.com",
  ) {}

  send(to: string, subject: string) {
    return { from: this.from, to, subject, auth: this.apiKey.slice(0, 4) + "..." };
  }
}

This style is everywhere in Angular and NestJS for dependency injection. One caution: parameter properties are TypeScript-only syntax that generates runtime code, so they are rejected when you enable erasableSyntaxOnly (used when Node.js runs .ts files by simply stripping types). If your project uses that flag, write fields out explicitly.

Implementing interfaces

A class can promise to match one or more interfaces with implements. The compiler then checks that every required member exists with a compatible type. This lets other code depend on the interface rather than a concrete class, which makes swapping implementations (for example a fake in tests) easy.

interface Storage {
  get(key: string): string | undefined;
  set(key: string, value: string): void;
}

class MemoryStorage implements Storage {
  #data = new Map<string, string>();
  get(key: string) { return this.#data.get(key); }
  set(key: string, value: string) { this.#data.set(key, value); }
}

function saveTheme(store: Storage, theme: "light" | "dark") {
  store.set("theme", theme);   // works with any Storage implementation
}

Note that implements only checks; it does not change the class's type or add anything. TypeScript's type system is structural, so any object with matching get and set methods could be passed to saveTheme, with or without the keyword.

Abstract classes

An abstract class is a base class that cannot be instantiated on its own. It can contain shared, working code plus abstract members that each subclass must provide. Use it when subclasses share real behaviour; if you only need a contract, an interface is simpler.

abstract class Notifier {
  constructor(protected readonly appName: string) {}

  abstract deliver(to: string, text: string): Promise<void>;

  async notify(to: string, text: string) {
    await this.deliver(to, `[${this.appName}] ${text}`);   // shared logic
  }
}

class SmsNotifier extends Notifier {
  async deliver(to: string, text: string) {
    console.log(`SMS to ${to}: ${text}`);
  }
}

// new Notifier("x");  // Error: Cannot create an instance of an abstract class
const sms = new SmsNotifier("stackcone");
await sms.notify("+91 90000 00000", "Your order shipped");

When a subclass overrides a method, add the override keyword and enable noImplicitOverride in tsconfig. Then if the base method is renamed, the compiler flags every stale override instead of silently leaving an orphan method.

Recap

  • Declare typed fields; strict mode requires each to be initialized or assigned in the constructor.
  • private/protected are compile-time checks; #field is real runtime privacy.
  • readonly fields can only be set at creation; parameter properties shorten constructor boilerplate.
  • implements checks a class against an interface; code should depend on the interface.
  • Abstract classes share implementation and force subclasses to fill in abstract members.
// Write your solution here

Finished reading? Mark this lesson complete to track your progress.

Up next · Lesson 7Mapped, Conditional and Template Literal TypesMaster advanced TypeScript types: keyof, indexed access, mapped types with key remapping, conditional types with infer, and template literal types.