Classes & Access Modifiers

TypeScript · Lesson 20 of 28 6 min read Updated

JavaScript classes work the same in TypeScript, but TypeScript adds types for fields, parameters and return values, plus access modifiers that control who may use each member. It also checks that every field gets a value, that subclasses override methods correctly and that a class really provides everything an interface promises.

This lesson assumes you know JavaScript classes. If not, read Classes in the JavaScript tutorial first. Here you will add types, use public, private, protected and readonly, compare TypeScript's private with JavaScript's #private, and write interfaces, abstract classes and overrides.

Typed fields and constructors

In TypeScript, class fields are declared in the class body with a type. The constructor fills them in.

TypeScriptRun it yourself
class Ticket {
  readonly pnr: string;
  passenger: string;
  seat: number | null = null;
  #fare: number;

  constructor(pnr: string, passenger: string, fare: number) {
    this.pnr = pnr;
    this.passenger = passenger;
    this.#fare = fare;
  }

  allot(seat: number): void {
    this.seat = seat;
  }

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

  summary(): string {
    const seatText = this.seat === null ? "RAC" : `seat ${this.seat}`;
    return `${this.pnr}: ${this.passenger}, ${seatText}, ₹${this.fare}`;
  }
}

const t = new Ticket("4521890", "Ananya", 1450);
console.log(t.summary());
t.allot(23);
console.log(t.summary());
console.log(Object.keys(t));
Output
4521890: Ananya, RAC, ₹1450
4521890: Ananya, seat 23, ₹1450
[ 'pnr', 'passenger', 'seat' ]

readonly pnr can be set in the constructor but never changed afterwards. #fare is a JavaScript private field: it is invisible outside the class and does not appear in Object.keys(). The getter fare gives read-only access to it.

With strict on, TypeScript checks that every field is definitely given a value, either where it is declared or in the constructor. It also enforces the modifiers:

TypeScript
class Wallet {
  owner: string;
  private balance = 0;
  protected history: string[] = [];
  readonly id: number;

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

class Profile {
  name: string;
}

const w = new Wallet("Isha", 1);
w.balance = 5000;
w.history.push("hack");
w.id = 2;
Output
app.ts(14,3): error TS2564: Property 'name' has no initializer and is not definitely assigned in the constructor.
app.ts(18,3): error TS2341: Property 'balance' is private and only accessible within class 'Wallet'.
app.ts(19,3): error TS2445: Property 'history' is protected and only accessible within class 'Wallet' and its subclasses.
app.ts(20,3): error TS2540: Cannot assign to 'id' because it is a read-only property.

Access modifiers

TypeScript has three access modifiers:

ModifierWho can use the member
public (the default)Everyone
protectedThe class and its subclasses
privateOnly the class itself

There is an important difference between TypeScript's private keyword and JavaScript's # private fields. The keyword is checked only by the compiler and is erased; at run time the property is a normal, visible property. A # field is private in the running JavaScript too.

TypeScriptRun it yourself
class Account {
  private pin = "4321";   // TypeScript-only privacy
  #otp = "889102";        // real JavaScript privacy

  check(pin: string): boolean {
    return pin === this.pin;
  }
}

const acc = new Account();
console.log(acc.check("4321"));
console.log(JSON.stringify(acc));
console.log(Object.keys(acc));
Output
true
{"pin":"4321"}
[ 'pin' ]

The private pin still appears in JSON.stringify and Object.keys, and plain JavaScript code (or acc["pin"]) can read it. The #otp field appears nowhere. Use # fields for data that must stay hidden, and the private keyword when compile-time protection is enough or when you work in code that already uses it.

Interfaces, abstract classes and override

A class can promise to match an interface with implements. TypeScript then checks that every required member exists with the right type. An abstract class is a base class that cannot be created with new and can declare abstract methods that each subclass must write.

TypeScriptRun it yourself
interface Payable {
  amountDue(): number;
}

abstract class Employee implements Payable {
  readonly name: string;
  protected baseSalary = 30000;

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

  abstract role(): string;

  amountDue(): number {
    return this.baseSalary;
  }

  describe(): string {
    return `${this.name} (${this.role()}): ₹${this.amountDue()}`;
  }
}

class Developer extends Employee {
  role(): string {
    return "Developer";
  }

  override amountDue(): number {
    return this.baseSalary + 25000;
  }
}

class Intern extends Employee {
  role(): string {
    return "Intern";
  }

  override amountDue(): number {
    return Math.round(this.baseSalary * 0.4);
  }
}

const team: Employee[] = [new Developer("Kabir"), new Intern("Priya")];
for (const person of team) {
  console.log(person.describe());
}
const total = team.reduce((sum, p) => sum + p.amountDue(), 0);
console.log(`Payroll: ₹${total}`);
Output
Kabir (Developer): ₹55000
Priya (Intern): ₹12000
Payroll: ₹67000
  • implements Payable makes the compiler check that amountDue() exists and returns a number.
  • abstract role() has no body; new Employee("x") would be an error, and any subclass that forgets role() is an error too.
  • protected baseSalary is usable in Developer and Intern but not from outside.
  • override marks a method that replaces a parent method. With the noImplicitOverride option, TypeScript requires it, and it reports an error if the parent method is renamed, so overrides never silently break.

Parameter properties: the older shortcut

TypeScript has a shortcut that declares and assigns a field in one go, by putting a modifier on a constructor parameter. These are called parameter properties, and you will see them in many existing projects, especially Angular:

TypeScript
// Older style: parameter properties (not erasable)
class Student {
  constructor(public name: string, private rollNo: number) {}
}

// Modern, erasable equivalent
class StudentModern {
  name: string;
  #rollNo: number;

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

Parameter properties generate assignment code, so they are not erasable syntax: Node.js type stripping rejects them, and the erasableSyntaxOnly option reports them (see Enums & Erasable Syntax). For new code, prefer declaring fields explicitly as in StudentModern. It is a few lines longer but works with every tool.

Common mistakes

  • Thinking private hides data at run time. It does not; use #fields for real privacy.
  • Forgetting to initialise a field. Give it a default, set it in the constructor, or make it optional with ?.
  • Calling an abstract class with new. Create one of its subclasses instead.
  • Overriding by accident. Turn on noImplicitOverride so every override is marked and checked.
  • Using parameter properties in Node.js code. They fail with type stripping; declare fields explicitly.

Key points

  • Declare class fields with types; strict mode checks that each field gets a value.
  • public, protected and private control access at compile time; #private fields are private at run time too.
  • readonly fields can be assigned only in their declaration or the constructor.
  • implements checks a class against an interface; abstract classes define members subclasses must provide.
  • override marks replaced methods; parameter properties are an older, non-erasable shortcut.

Exercise

Create an interface Shape with name: string and area(): number. Write an abstract class BaseShape that implements it and adds a method report(): string returning text like Circle: 78.54 sq cm. Create Circle (radius) and Rectangle (width, height) subclasses with explicitly declared fields. Put one of each in an array and log every report.

Show solution
TypeScript
interface Shape {
  name: string;
  area(): number;
}

abstract class BaseShape implements Shape {
  abstract name: string;
  abstract area(): number;

  report(): string {
    return `${this.name}: ${this.area().toFixed(2)} sq cm`;
  }
}

class Circle extends BaseShape {
  name = "Circle";
  #radius: number;
  constructor(radius: number) {
    super();
    this.#radius = radius;
  }
  area(): number {
    return Math.PI * this.#radius ** 2;
  }
}

class Rectangle extends BaseShape {
  name = "Rectangle";
  width: number;
  height: number;
  constructor(width: number, height: number) {
    super();
    this.width = width;
    this.height = height;
  }
  area(): number {
    return this.width * this.height;
  }
}

const shapes: Shape[] = [new Circle(5), new Rectangle(4, 6.5)];
for (const s of shapes) {
  if (s instanceof BaseShape) console.log(s.report());
}
// Circle: 78.54 sq cm
// Rectangle: 26.00 sq cm

BaseShape declares the members every shape must have, and each subclass declares its own fields explicitly, so the code is erasable and works with Node.js type stripping.

Quick Quiz

1. Where can a protected member be used?

2. What is the run-time difference between private x and #x?

3. What does implements Payable do?

4. Why do parameter properties like constructor(public name: string) fail with Node.js type stripping?

Learn TypeScript with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call