Loading
All articles
Programming 15 min read

TypeScript Protected Members: A Visual, Hands-On Guide

Learn TypeScript protected members visually: why subclasses can't reach private data, how protected fixes it, protected methods, the instance rule, changing access in a subclass, why protected data can break a class's rules, what it can't catch, and exercises.

nbkhope
On this page

In object-oriented programming, an object's data is usually private, so that it can't be read or changed directly. Other code goes through getters and setters instead. That works until you add a subclass: it's part of the family, and yet it's locked out as well.

In this last guide of the series you'll meet the level between public and private: protected. You'll use it on properties and methods, learn a rule that surprises even experienced developers, see how protected data can quietly break a class, and find out what protected can't stop. Exercises wrap it up.

This guide builds on TypeScript Class Inheritance. All outputs below come from real runs with TypeScript 7.0 and Node.js 26 on Windows. Save each example as main.ts, then run npx tsc main.ts and node main.js.

Private data

Here's a class that represents a vehicle. Its brand is private, and a getter and a setter control access to it:

1class Vehicle {
2  private brand: string;
3
4  constructor(brand: string) {
5    this.brand = brand;
6  }
7
8  getBrand(): string {
9    return this.brand;
10  }
11
12  setBrand(newBrand: string): void {
13    this.brand = newBrand;
14  }
15}
16
17const car = new Vehicle("Honda");
18console.log(car.getBrand());
19
20console.log(car.brand);
21car.brand = "Toyota";
main.ts(20,17): error TS2341: Property 'brand' is private and only accessible within class 'Vehicle'.
main.ts(21,5): error TS2341: Property 'brand' is private and only accessible within class 'Vehicle'.

The getter works, and both direct uses are rejected. That's what we want.

Locked out of the family

Now let's derive a class from Vehicle. Car inherits the constructor, so it doesn't need one of its own:

1class Vehicle {
2  private brand: string;
3
4  constructor(brand: string) {
5    this.brand = brand;
6  }
7}
8
9class Car extends Vehicle {
10  printBrand(): void {
11    console.log(this.brand);
12  }
13}
14
15new Car("Honda").printBrand();
main.ts(11,22): error TS2341: Property 'brand' is private and only accessible within class 'Vehicle'.
Honda

Every Car has a brand, and Car still can't touch it. private means this class only, and a subclass is a different class.

protected

Change private to protected, and the subclass gets in, while code outside stays out:

1class Vehicle {
2  protected brand: string;
3
4  constructor(brand: string) {
5    this.brand = brand;
6  }
7}
8
9class Car extends Vehicle {
10  printBrand(): void {
11    console.log(this.brand);
12  }
13}
14
15const car = new Car("Honda");
16car.printBrand();
17console.log(car.brand);
main.ts(17,17): error TS2445: Property 'brand' is protected and only accessible within class 'Vehicle' and its subclasses.
Honda
Honda

That completes the picture of who can reach a member:

Who can reach a class member? A public member can be used inside the class, in a subclass and outside, and it can be read at run time. A protected member can be used inside the class and in a subclass, outside it gives TS2445, and it can still be read at run time. A private member can be used inside the class, in a subclass and outside it gives TS2341, and it can still be read at run time. A # member can be used inside the class, in a subclass and outside it gives TS18013, and it's blocked at run time

Protected methods

Methods can be protected too, and that's where the keyword is often most useful. A base class can define the steps of a job, and let each subclass fill in one step that nobody else should call:

1abstract class Report {
2  print(): void {
3    console.log("--- Report ---");
4    console.log(this.getBody());
5    console.log("--- End ---");
6  }
7
8  protected abstract getBody(): string;
9}
10
11class SalesReport extends Report {
12  protected override getBody(): string {
13    return "Sales: 100";
14  }
15}
16
17const report = new SalesReport();
18report.print();
19report.getBody();
1main.ts(19,8): error TS2445: Property 'getBody' is protected and only accessible within class 'SalesReport' and its subclasses.
2--- Report ---
3Sales: 100
4--- End ---

Outside code calls print, which is public. getBody is an internal step, and hiding it keeps the class simple to use.

The instance rule

Here's the surprise. A subclass can use a protected member through this, or through another object of its own class. Through any other type, it can't:

1class Vehicle {
2  protected brand = "Honda";
3}
4
5class Truck extends Vehicle {}
6
7class Car extends Vehicle {
8  own(): string {
9    return this.brand;
10  }
11
12  sameClass(other: Car): string {
13    return other.brand;
14  }
15
16  baseClass(other: Vehicle): string {
17    return other.brand;
18  }
19
20  sibling(other: Truck): string {
21    return other.brand;
22  }
23}
main.ts(17,18): error TS2446: Property 'brand' is protected and only accessible through an instance of class 'Car'. This is an instance of class 'Vehicle'.
main.ts(21,18): error TS2446: Property 'brand' is protected and only accessible through an instance of class 'Car'. This is an instance of class 'Truck'.
Inside Car, which brand can you read? this.brand is allowed. other.brand is allowed when other is a Car. It gives error TS2446 when other is a Vehicle, and also when other is a Truck, because it could be another class's object

The reason: protected trusts a class with its own objects. A Vehicle variable might hold a Truck, and Car has no business reaching inside a Truck. Code inside Vehicle itself has no such limit, so a method there can compare any two vehicles:

1class Vehicle {
2  protected brand: string;
3
4  constructor(brand: string) {
5    this.brand = brand;
6  }
7
8  static sameBrand(first: Vehicle, second: Vehicle): boolean {
9    return first.brand === second.brand;
10  }
11}
12
13class Car extends Vehicle {}
14class Truck extends Vehicle {}
15
16console.log(Vehicle.sameBrand(new Car("Honda"), new Truck("Honda")));
true

Changing access in a subclass

A subclass can make a protected member public, by declaring it again:

1class Vehicle {
2  protected brand = "Honda";
3}
4
5class Car extends Vehicle {
6  public override brand = "Toyota";
7}
8
9console.log(new Car().brand);
Toyota

It can't go the other way, though. Code that works with a Vehicle expects its public members to be there, so a subclass can't take one away:

1class Vehicle {
2  brand = "Honda";
3}
4
5class Car extends Vehicle {
6  protected override brand = "Toyota";
7}
main.ts(5,7): error TS2415: Class 'Car' incorrectly extends base class 'Vehicle'.
  Property 'brand' is protected in type 'Car' but public in type 'Vehicle'.

Combining protected

protected works with the features from the earlier guides. On a constructor parameter it creates a property, and with readonly subclasses can read it and not change it:

1class Vehicle {
2  constructor(protected brand: string, protected readonly year: number) {}
3}
4
5class Car extends Vehicle {
6  describe(): string {
7    return `${this.brand} ${this.year}`;
8  }
9
10  changeYear(): void {
11    this.year = 2000;
12  }
13}
14
15console.log(new Car("Honda", 2020).describe());
main.ts(11,10): error TS2540: Cannot assign to 'year' because it is a read-only property.
Honda 2020

JavaScript's # doesn't combine with it. A # member is always private, and there's no # version of protected:

class Vehicle {
  protected #brand = "Honda";
}
main.ts(2,3): error TS18010: An accessibility modifier cannot be used with a private identifier.

Protected data can break the rules

protected is a real opening in a class, and it's worth being careful with. This account protects itself from bad deposits, but its balance is protected, so a subclass can skip the check:

1class Account {
2  protected balance = 0;
3
4  deposit(amount: number): void {
5    if (amount <= 0) {
6      throw new Error(`Invalid amount: ${amount}`);
7    }
8    this.balance += amount;
9  }
10
11  getBalance(): number {
12    return this.balance;
13  }
14}
15
16class BonusAccount extends Account {
17  addBonus(): void {
18    this.balance -= 1000;
19  }
20}
21
22const account = new BonusAccount();
23account.deposit(100);
24account.addBonus();
25console.log(account.getBalance());
-900

This compiled with no errors, and the balance is negative. The safer design keeps the data private, and offers subclasses a protected method that goes through the check:

1class Account {
2  private balance = 0;
3
4  protected addToBalance(amount: number): void {
5    if (amount <= 0) {
6      throw new Error(`Invalid amount: ${amount}`);
7    }
8    this.balance += amount;
9  }
10
11  getBalance(): number {
12    return this.balance;
13  }
14}
15
16class BonusAccount extends Account {
17  addBonus(): void {
18    this.addToBalance(-1000);
19  }
20}
21
22const account = new BonusAccount();
23
24try {
25  account.addBonus();
26} catch (error) {
27  console.log(String(error));
28}
29
30console.log(account.getBalance());
Error: Invalid amount: -1000
0
Protected data or a protected method? With protected data, the subclass writes straight to the balance, no check runs, and the balance ends at -900. With private data and a protected method, the subclass calls addToBalance, the check runs and rejects the amount, and the balance stays at 0

If subclasses only need to read a value, a protected getter does the job. With no setter, they can't change it:

1class Vehicle {
2  #brand = "Honda";
3
4  protected get brand(): string {
5    return this.#brand;
6  }
7}
8
9class Car extends Vehicle {
10  show(): string {
11    return this.brand;
12  }
13
14  change(): void {
15    this.brand = "Toyota";
16  }
17}
18
19console.log(new Car().show());
main.ts(15,10): error TS2540: Cannot assign to 'brand' because it is a read-only property.
Honda

What protected can't catch

Like private, protected is removed from the JavaScript. Nothing guards the value when the program runs:

1class Vehicle {
2  protected brand = "Honda";
3}
4
5const car = new Vehicle();
6
7console.log(car);
8console.log(JSON.stringify(car));
Vehicle { brand: 'Honda' }
{"brand":"Honda"}

There's also a gap inside the compiler itself. Square brackets are an intentional way around the check for both private and protected, so this compiles with no errors:

1class Vehicle {
2  protected brand = "Honda";
3}
4
5const car = new Vehicle();
6console.log(car["brand"]);
Honda

The check does help in one way you've seen before. Unlike a class with only public members, a class with a protected member can't be imitated by a plain object:

1class Vehicle {
2  protected brand = "Honda";
3}
4
5const imitation: Vehicle = { brand: "Honda" };
main.ts(5,7): error TS2322: Type '{ brand: string; }' is not assignable to type 'Vehicle'.
  Property 'brand' is protected but type '{ brand: string; }' is not a class derived from 'Vehicle'.

Choosing an access level

Start closed, and open only as far as you need:

Who needs it?Use
Only this classprivate, or # if it must hold at run time
Subclasses need to read a valueA protected getter on private data
Subclasses need to change a valueA protected method with the checks in it
Subclasses must supply a stepA protected abstract method
Everyonepublic

Moving a member from private to protected later is easy. Taking it back is hard, because subclasses may depend on it by then.

Your turn: practice

Try these before peeking at the answers.

Exercise 1: Predict both tools (warm-up). What does tsc report for this program, and what does node main.js print afterward?

1class Employee {
2  protected salary: number;
3
4  constructor(salary: number) {
5    this.salary = salary;
6  }
7}
8
9class Manager extends Employee {
10  raise(): void {
11    this.salary += 10;
12  }
13
14  compare(other: Employee): boolean {
15    return this.salary > other.salary;
16  }
17}
18
19const manager = new Manager(100);
20manager.raise();
21console.log(manager.compare(new Employee(50)));
22console.log(manager.salary);

Exercise 2: Fix it. Change one word so that this program compiles, and name stays hidden from outside code.

1class Animal {
2  private name: string;
3
4  constructor(name: string) {
5    this.name = name;
6  }
7}
8
9class Dog extends Animal {
10  speak(): string {
11    return `${this.name} barks`;
12  }
13}
14
15console.log(new Dog("Rex").speak());

Exercise 3: Drinks. Write an abstract class Drink with a public prepare(): string method that returns Boil water, then <step>, and a protected abstract getStep(): string method. Write Tea and Coffee subclasses, and print the result of prepare() for each.

Exercise 4: Safe counter. Write a Counter class with a private count, a public getCount(), and a protected increase(amount: number) method that throws an error for amounts of 0 or less. Write a DoubleCounter subclass with a public tick() method that increases by 2. Tick twice and print the count.

Exercise 5: Payroll (challenge). Use everything from this series. Write an abstract class Employee with a private #salary set by the constructor, a protected getter salary, a protected abstract getBonus(): number, and a public getPay(): number that adds both. Write a Manager, whose bonus is 10% of the salary, and an Intern, whose bonus is 0. Write totalPay(employees: Employee[]): number, and try it with a manager earning 200 and an intern earning 100.

Quick quiz

  1. What's the difference between private and protected?
  2. Which error do you get when outside code uses a protected property?
  3. Why might a method be protected and not public?
  4. Can Car read other.brand when other is a Car? And when it's a Vehicle?
  5. Can a subclass make a protected member public? And a public member protected?
  6. Is there a # version of protected?
  7. Why is protected data riskier than a protected method?
  8. Does protected hide a value when the program runs?
  9. Which access level should you start with?

Answers

Exercise 1: compare receives an Employee, not a Manager, and the last line is outside the classes. tsc still writes main.js, and nothing stops it from running:

1main.ts(15,32): error TS2446: Property 'salary' is protected and only accessible through an instance of class 'Manager'. This is an instance of class 'Employee'.
2main.ts(22,21): error TS2445: Property 'salary' is protected and only accessible within class 'Employee' and its subclasses.
3true
4110

Exercise 2: Change private to protected:

1class Animal {
2  protected name: string;
3
4  constructor(name: string) {
5    this.name = name;
6  }
7}
8
9class Dog extends Animal {
10  speak(): string {
11    return `${this.name} barks`;
12  }
13}
14
15console.log(new Dog("Rex").speak());
Rex barks

Exercise 3: The base class owns the steps, and each subclass fills in its own:

1abstract class Drink {
2  prepare(): string {
3    return `Boil water, then ${this.getStep()}`;
4  }
5
6  protected abstract getStep(): string;
7}
8
9class Tea extends Drink {
10  protected override getStep(): string {
11    return "add the tea bag";
12  }
13}
14
15class Coffee extends Drink {
16  protected override getStep(): string {
17    return "pour over the ground coffee";
18  }
19}
20
21console.log(new Tea().prepare());
22console.log(new Coffee().prepare());
Boil water, then add the tea bag
Boil water, then pour over the ground coffee

Exercise 4: The subclass can only go through increase, so the check always runs:

1class Counter {
2  private count = 0;
3
4  getCount(): number {
5    return this.count;
6  }
7
8  protected increase(amount: number): void {
9    if (amount <= 0) {
10      throw new Error(`Invalid amount: ${amount}`);
11    }
12    this.count += amount;
13  }
14}
15
16class DoubleCounter extends Counter {
17  tick(): void {
18    this.increase(2);
19  }
20}
21
22const counter = new DoubleCounter();
23counter.tick();
24counter.tick();
25
26console.log(counter.getCount());
4

Exercise 5: Subclasses can read the salary through the getter, and nothing outside the base class can change it:

1abstract class Employee {
2  #salary: number;
3
4  constructor(salary: number) {
5    this.#salary = salary;
6  }
7
8  protected get salary(): number {
9    return this.#salary;
10  }
11
12  protected abstract getBonus(): number;
13
14  getPay(): number {
15    return this.salary + this.getBonus();
16  }
17}
18
19class Manager extends Employee {
20  protected override getBonus(): number {
21    return this.salary * 0.1;
22  }
23}
24
25class Intern extends Employee {
26  protected override getBonus(): number {
27    return 0;
28  }
29}
30
31function totalPay(employees: Employee[]): number {
32  return employees.reduce((total, employee) => total + employee.getPay(), 0);
33}
34
35console.log(totalPay([new Manager(200), new Intern(100)]));
320

Quiz: (1) private works only inside the class that declares it, and protected also works in its subclasses. (2) TS2445. (3) It's an internal step that subclasses need and outside code shouldn't call. (4) Yes, because it's the class's own type. No, because a Vehicle could be another subclass, which gives TS2446. (5) Yes, by declaring it again as public. No, because code that uses the base class expects it to be there. (6) No. A # member is always private. (7) A subclass can change data directly and skip the class's checks, while a method can keep them. (8) No. It's removed from the JavaScript, and square brackets get past the compiler too. (9) private, and open up only when something needs it.

Cheat sheet

I want to…Write
Share a property with subclassesprotected brand: string;
Share a method with subclassesprotected getBody(): string { … }
Require a hidden step in subclassesprotected abstract getBody(): string;
Declare it in the constructorconstructor(protected brand: string) {}
Let subclasses read but not changeprotected readonly year: number;
Expose private data to subclasses, read-onlyprotected get brand(): string { … }
Let subclasses change data safelyprotected addToBalance(amount: number): void { … }
Make a protected member publicpublic override brand = "Toyota";

Wrapping up

protected opens a class to its family, and no further:

  • private stops at the class that declares it, so subclasses are locked out too.
  • protected also lets subclasses in, and outside code gets TS2445.
  • Protected methods hide internal steps from outside code.
  • A subclass reaches protected members only through its own type, or you get TS2446.
  • A subclass can make a member more public, but not less.
  • There's no # version of protected.
  • Prefer private data with protected methods, so a class keeps its rules.
  • protected is removed from the JavaScript, and square brackets get past the compiler.
  • Start with private, and open up only when you need to.

That's the end of this TypeScript series. Here's the whole path, in order:

  1. Function parameter types
  2. Function return types
  3. Function types
  4. Introduction to classes
  5. Class inheritance
  6. Protected constructors
  7. Protected members, this guide

Thanks for reading!

Know someone who would enjoy this? Send it their way.

More articles

Read next

Programming 4 min read

Ruby Blocks and Iterators: Write Your Own

Learn how Ruby methods receive blocks, pass values with yield, handle a missing block, and return an Enumerator. Build and run your own iterator.

nbkhope
Programming 6 min read

C++ std::vector: Size, Capacity, and Safe Growth

Learn how std::vector grows in C++: the difference between size and capacity, when reserve helps, why resize is different, and how growth can invalidate references.

nbkhope
Programming 5 min read

How to Handle Errors in Go: A Hands-On Guide

Learn Go's everyday error pattern with runnable examples: return an error, check it, add context with %w, and recognize a missing file with errors.Is.

nbkhope

Comments

Be kind and specific. Comments with links from new accounts appear after a moderator reviews them.