There's more to learn about classes. In this chapter, we'll discuss:
By default, object properties and methods are public, which means they can be accessed at any time by a user of an instance:
let database = {
getStudentId(name) {
// This method should probably access a database
return 4201567;
}
};
class Student {
constructor(firstName, lastName, track) {
this.name = [firstName, lastName];
this.track = track;
}
// This method shouldn't be public!
revealStudentId() {
let studentId = database.getStudentId(this.name);
console.log(studentId);
}
}
let student = new Student('Kay', 'Oakley', 'JavaScript');
console.log(`${student.name.join(' ')} ${student.track}`);
// Kay Oakley JavaScript
// Reveals sensitive info about student
student.revealStudentId(); // 4201567
student.name = ['Bill', 'Wisner'];
student.track = 'Ruby';
console.log(`${student.name.join(' ')} ${student.track}`);
// Bill Wisner Ruby
While such openness provides considerable flexibility, it can lead to bugs or reveal sensitive information. It may also make it hard to update your classes and types. When other developers use your code and come to rely on the properties defined for your objects, there will be complaints if you try to change the way things are done.
One solution to these problems is to use private fields and methods.
Ordinary object properties are referred to by various terms, such as instance properties, public instance properties, or, confusingly, public class fields. Similarly, ordinary object methods may be called instance methods, public instance methods, or public class methods. These variations in terminology can be very confusing as you browse the various resources available for JavaScript.
Private fields, also known as private instance properties or, confusingly, private class fields, are object properties that are private to an object. No other objects can access them directly. This aids encapsulation and prevents your class's users from misusing your object properties. Private fields help you to change your implementation without breaking other users' code.
Private fields are accessed like instance properties, except the name of the property is prefixed by a #
character. In addition, you must declare (and optionally initialize) the field at the class level:
class Foo {
//highlight
// Next 2 lines declare #data and #initializedData fields
#data; // uninitialized private field
#initializedData = 43; // initialized private field
constructor(value) {
this.#data = value;
}
show() {
console.log(this.#data, this.#initializedData);
}
//endhighlight
}
//highlight
let foo = new Foo(42);
foo.show(); // 42 43
//endhighlight
Thus, we can rewrite the Student
class above to use private fields:
class Student {
#name;
#track;
constructor(firstName, lastName, track) {
this.#name = [firstName, lastName];
this.#track = track;
}
}
let student = new Student('Kay', 'Oakley', 'JavaScript');
//highlight
console.log(`${student.#name.join(' ')} ${student.#track}`);
// SyntaxError: Private field '#name' must be declared in an
// enclosing class
//endhighlight
As you can see, we can't access the private fields outside of the class. If you want to allow your users to access the values in the private fields, you can define methods to retrieve the values of the #name
and #track
properties:
class Student {
#name;
#track;
constructor(firstName, lastName, track) {
this.#name = [firstName, lastName];
this.#track = track;
}
//highlight
name() {
return this.#name;
}
track() {
return this.#track;
}
//endhighlight
}
let student = new Student('Kay', 'Oakley', 'JavaScript');
console.log(`${student.name().join(' ')} ${student.track()}`);
//highlight
// Kay Oakley JavaScript
//endhighlight
You can also define methods to set the values of the private instance properties. However, we won't do that now. We'll talk about that when we talk about setters.
Private instance properties are a relatively recent feature of JavaScript. The feature was finalized in 2022. As of 2024, all major browsers and Node have added them, but older versions may not yet support them.
Private methods, also known as private instance methods or, confusingly, private class methods, are instance methods that are private to an object. These methods can only be used within the class. Otherwise, they look and act like ordinary methods except that their names begin with a #
and can't be called from outside the class:
class MyClass {
myPublic() {
return this.#myPrivate();
}
#myPrivate() {
return 'This is a private method';
}
}
const instance = new MyClass();
console.log(instance.myPublic()); // This is a private method
console.log(instance.#myPrivate());
// Error: Private field '#myPrivate' must be declared in an
// enclosing class
Private methods are a great solution when there are certain operations you need inside your objects but don't want to make those operations available to users of your objects:
class Student {
#name;
#track;
constructor(firstName, lastName, track) {
this.#name = [firstName, lastName];
this.#track = track;
}
#revealStudentId() {
let studentId = database.getStudentId(this.#name);
console.log(studentId.idNumber);
}
}
let student = new Student('Kay', 'Oakley', 'JavaScript');
student.#revealStudentId(); // Raises an error!
Private fields aren't always the right choice. Sometimes, you want your object properties to be available to the users of your objects. However, if you allow that, you'll be constrained in your implementation updates in the future. Suppose you decide to change something that affects one of your object properties. In that case, that change will have a ripple effect on the users of your objects who use those properties.
Unrestricted access to your properties also means users can reassign them to anything they want, including values that do not make sense in the application's context. Ideally, you may wish to constrain what values can be assigned to a property.
One solution to these problems is to use getters and setters. Getters and setters are methods that work like ordinary properties; you can access them without parentheses and assign them values. However, they give you a lot more control than ordinary properties would.
Let's consider our Student
class:
class Student {
constructor(firstName, lastName, track) {
this.name = [firstName, lastName];
this.track = track;
}
}
let student = new Student('Kay', 'Oakley', 'JavaScript');
console.log(`${student.name.join(' ')} ${student.track}`);
// Kay Oakley JavaScript
Since we let users access the properties directly, we've boxed ourselves in. We can no longer change how the various properties are stored. For instance, suppose we decide to store the first and last names separately instead of as an array:
class Student {
constructor(firstName, lastName, track) {
//highlight
this.firstName = firstName;
this.lastName = lastName;
//endhighlight
this.track = track;
}
}
let student = new Student('Kay', 'Oakley', 'JavaScript');
console.log(`${student.name.join(' ')} ${student.track}`);
// TypeError: Cannot read properties of undefined(reading 'join');
Suddenly, our users will begin experiencing errors or bugs when they try to access the name
property as an Array
. Changing the way we store our state has changed the class's API.
There are some workarounds we can use:
firstName
and lastName
properties instead of the name
array. This is impolite and may lead users to abandon your code in favor of another, more stable package. Some may decide they won't upgrade, which is bad if you later have to fix a security problem in your code.
name()
method that returns the first and last names as an Array
. Unfortunately, users still have to update their code.
None of these workarounds are satisfactory. A solution that does work well is to create a getter for the former name
property:
class Student {
constructor(firstName, lastName, track) {
this.firstName = firstName;
this.lastName = lastName;
this.track = track;
}
//highlight
get name() {
return [this.firstName, this.lastName];
}
//endhighlight
}
let student = new Student('Kay', 'Oakley', 'JavaScript');
console.log(`${student.name.join(' ')} ${student.track}`);
// Kay Oakley JavaScript
Bingo! That did the trick.
Getters are a special kind of method you can define in any object. Internally, they look like methods with the keyword get
preceding a method definition that uses concise method syntax. However, getters aren't used like methods. Instead, they are used like ordinary instance properties: you reference the getter name to retrieve the corresponding data. You don't have to, nor can you, use the parentheses of a method call.
You can define getters in any object. You don't have to use a class. For instance, in the code below, we've defined a getter to make it easy to get the full name:
let teacher = {
firstName: 'Alan',
lastName: 'Stone',
get fullName() {
return `${this.firstName} ${this.lastName}`;
},
};
console.log(teacher.fullName); // Alan Stone
Consider another use case for getters. Suppose your object has a property whose value is mutable. Suppose you allow direct access to that property. In that case, users can modify the mutable value, which may be undesirable. In that case, you can write a getter that returns a copy of the mutable property's value:
let teacher = {
firstName: 'Alan',
lastName: 'Stone',
_students: ['Pete', 'Brian', 'Andrea', 'Beverly', 'Joel'],
get fullName() {
return `${this.firstName} ${this.lastName}`;
},
get students() {
return [...this._students]; // A copy of the _students array
},
};
console.log(teacher.fullName); // Alan Stone
let students = teacher.students;
console.log(students);
// [ 'Pete', 'Brian', 'Andrea', 'Beverly', 'Joel' ]
students.pop();
console.log(students);
// [ 'Pete', 'Brian', 'Andrea', 'Beverly' ]
console.log(teacher.students);
// [ 'Pete', 'Brian', 'Andrea', 'Beverly', 'Joel' ]
By returning a copy of the _students
array, we can manipulate the return value without impacting the teacher
object.
Note that we had to change the name of the students
property to _students
. If you don't do this, you'll experience a stack overflow due to recursive calls to the students
getter. A single leading underscore is a JavaScript convention that marks something as intended for internal use only. So, while users can use either teacher.students
or teacher._students
to access the _students
property, they should only use teacher.students
.
You can also create getters that merely return a property's value. For instance, we can add firstName
, lastName
, and track
getters to the Student
class. Again, you will have to rename the firstName
and lastName
properties:
class Student {
constructor(firstName, lastName, track) {
//highlight
this._firstName = firstName;
this._lastName = lastName;
this._track = track;
//endhighlight
}
get name() {
return [this.firstName, this.lastName];
}
//highlight
get firstName() {
return this._firstName;
}
get lastName() {
return this._lastName;
}
get track() {
return this._track;
}
//endhighlight
}
let student = new Student('Kay', 'Oakley', 'JavaScript');
console.log(`${student.name.join(' ')} ${student.track}`);
// Kay Oakley JavaScript
console.log(`${student.firstName} ${student.lastName}`);
// Kay Oakley
The practicality of creating such getters is limited. The best use case is preparation for making the actual properties private. We'll see an example of that momentarily.
Note that we don't need the underscores in the name
getter. That getter uses the firstName
and lastName
getters to retrieve the values of the _firstName
and _lastName
properties.
Instead of the single underscore, you can also use private fields:
class Student {
//highlight
#firstName;
#lastName;
#track;
//endhighlight
constructor(firstName, lastName, track) {
//highlight
this.#firstName = firstName;
this.#lastName = lastName;
this.#track = track;
//endhighlight
}
get name() {
return [this.firstName, this.lastName];
}
get firstName() {
//highlight
return this.#firstName;
//endhighlight
}
get lastName() {
//highlight
return this.#lastName;
//endhighlight
}
get track() {
//highlight
return this.#track;
//endhighlight
}
}
Let's add some validation for our track
property. Its value should be either JavaScript
, Python
, or Ruby
. Anything else should raise an error:
class Student {
#firstName;
#lastName;
#track;
constructor(firstName, lastName, track) {
this.#firstName = firstName;
this.#lastName = lastName;
//highlight
switch (track) {
case 'JavaScript':
case 'Python':
case 'Ruby':
this.#track = track;
break;
default:
throw new Error(`Invalid track: '${track}'`);
}
//endhighlight
}
get name() {
return [this.firstName, this.lastName];
}
get firstName() {
return this.#firstName;
}
get lastName() {
return this.#lastName;
}
get track() {
return this.#track;
}
}
let student2 = new Student('Kay', 'Oakley', 'JavaScript');
console.log(`${student2.name.join(' ')} ${student2.track}`);
// Kay Oakley JavaScript
let student3 = new Student('Bill', 'Wisner', 'Python');
console.log(`${student3.name.join(' ')} ${student3.track}`);
// Bill Wisner Python
let student4 = new Student('Kim', 'Serkes', 'Gnome');
console.log(`${student4.name.join(' ')} ${student4.track}`);
// Invalid track: 'Gnome'
Suppose we want to allow users of the Student
class to change the track. We could create a setTrack
method that would validate the track name, but class users would have to update their code and call setTrack
instead of using a simpler assignment. To address this, we'll create a setter method.
Setters, like getters, are special methods you can define in any object. Internally, a setter looks like a concise syntax method definition preceded by the keyword set
. A setter takes a single argument: the value you want to assign to the property. Outside of the method, however, it looks just like an ordinary property: you assign a value to what looks like a property in your object. You don't have to, nor can you, use the parentheses of a method call.
class Student {
#firstName;
#lastName;
#track;
constructor(firstName, lastName, track) {
this.#firstName = firstName;
this.#lastName = lastName;
switch (track) {
case 'JavaScript':
case 'Python':
case 'Ruby':
this.#track = track;
break;
default:
throw new Error(`Invalid track: '${track}'`);
}
}
get name() {
return [this.firstName, this.lastName];
}
get firstName() {
return this.#firstName;
}
get lastName() {
return this.#lastName;
}
get track() {
return this.#track;
}
//highlight
set track(newTrack) {
switch (newTrack) {
case 'JavaScript':
case 'Python':
case 'Ruby':
this.#track = newTrack;
break;
default:
throw new Error(`Invalid track: '${newTrack}'`);
}
}
//endhighlight
}
let student2 = new Student('Kay', 'Oakley', 'JavaScript');
console.log(`${student2.name.join(' ')} ${student2.track}`);
// Kay Oakley JavaScript
let student3 = new Student('Bill', 'Wisner', 'Python');
console.log(`${student3.name.join(' ')} ${student3.track}`);
// Bill Wisner Python
//highlight
student3.track = 'Ruby';
console.log(`${student3.name.join(' ')} ${student3.track}`);
// Bill Wisner Ruby
student3.track = 'Baaa!';
console.log(`${student3.name.join(' ')} ${student3.track}`);
// Invalid track: 'Baaa!'
//endhighlight
In this code, the setter begins with the keyword set
followed by the property name and a pair of parentheses surrounding a single argument. After that, you have an ordinary function body that sets the value of a property.
Setters, like getters, must be written using syntax that resembles the concise method syntax. You can't use the function
keyword when defining a setter.
Like getters, you use setters just like ordinary properties, not methods. In other words, you write student.firstName = something
, not student.firstName(something)
.
We should probably do away with that code duplication in the constructor
method and the track
setter:
class Student {
#firstName;
#lastName;
#track;
constructor(firstName, lastName, track) {
this.#firstName = firstName;
this.#lastName = lastName;
//highlight
this.track = track; // we're calling the setter here
//endhighlight
}
get name() {
return [this.firstName, this.lastName];
}
get firstName() {
return this.#firstName;
}
get lastName() {
return this.#lastName;
}
get track() {
return this.#track;
}
set track(newTrack) {
switch (newTrack) {
case 'JavaScript':
case 'Python':
case 'Ruby':
this.#track = newTrack;
break;
default:
throw new Error(`Invalid track: '${newTrack}'`);
}
}
}
let student2 = new Student('Kay', 'Oakley', 'JavaScript');
console.log(`${student2.name.join(' ')} ${student2.track}`);
// Kay Oakley JavaScript
let student3 = new Student('Bill', 'Wisner', 'Python');
console.log(`${student3.name.join(' ')} ${student3.track}`);
// Bill Wisner Python
//highlight
student3.track = 'Ruby';
console.log(`${student3.name.join(' ')} ${student3.track}`);
// Bill Wisner Ruby
student3.track = 'Baaa!';
console.log(`${student3.name.join(' ')} ${student3.track}`);
// Invalid track: 'Baaa!'
//endhighlight
You can define setters in any object. You don't have to be using a class.
let teacher = {
firstName: 'Alan',
lastName: 'Stone',
get fullName() {
return `${this.firstName} ${this.lastName}`;
},
set name(nameArray) {
this.firstName = nameArray[0]
this.lastName = nameArray[1]
},
};
teacher.name = ['Mike', 'Becker']
console.log(teacher.fullName); // Mike Becker
In JavaScript, static fields and static methods are properties and methods that belong to the class itself rather than to class instances. They can be accessed directly on the class without needing to instantiate an object of that class.
Static fields, sometimes called static properties or class properties, are properties that are defined on the class itself. They are not tied to an instance of the class. Developers typically use static fields to store constants or utility data shared across all class instances. For example:
class MyClass {
static myField = 'This is a static field';
constructor() {
console.log(MyClass.myField); // Accessible inside the class
// using the class name
}
}
console.log(MyClass.myField); // Accessible outside the class
// using the class name
const instance = new MyClass();
console.log(instance.myField); // undefined: myField is not
// defined on this object.
A common use case for static fields is to keep track of something related to a class, such as an instance count:
class Student {
static counter = 0;
constructor(name) {
this.name = name;
Student.counter += 1;
}
}
console.log(Student.counter); // 0
let ken = new Student('Ken');
console.log(Student.counter); // 1
let lynn = new Student('Lynn');
console.log(Student.counter); // 2
Static methods, sometimes called class methods, are defined on a class rather than instance objects. They are not available from the class instances. Static methods typically provide utility functions that perform tasks relevant to the class but do not require access to instance-specific data. For example:
class MyClass {
static staticMethod() {
console.log('This is a static method.');
}
instanceMethod() {
console.log('This is an instance method.');
}
}
// Calling the static method
MyClass.staticMethod(); // This is a static method.
const instance = new MyClass();
instance.instanceMethod(); // This is an instance method.
instance.staticMethod(); // Raises error
A common use case for static methods is to report data relevant to a class:
class Student {
static counter = 0;
static showCounter() {
console.log(`We have created ${Student.counter} students!`);
}
constructor(name) {
this.name = name;
Student.counter += 1;
}
}
let ken = new Student('Ken');
let lynn = new Student('Lynn');
Student.showCounter(); // We have created 2 students!
In addition to the usual object properties and object methods, JavaScript classes support several additional flavors of properties and methods. All of the different types of properties and methods are known by several names:
Ordinary object properties are known by several synonymous terms: instance properties, public fields, and public class fields. Instance properties belong to a class's instance objects. By default, they are public. Any user of an object can access or update the values of those properties.
Likewise, ordinary object methods are variously known as instance methods, public methods, and public class methods. Instance methods belong to instance objects and are, by default, public. Any user of an object can call those methods.
Private fields are known by several synonymous terms: private properties, private instance properties, and private class fields. Private fields belong to a class's instance objects but are private: only the methods inside the class can access them. Private fields are named with a leading #
and must be declared before they can be used.
Likewise, private methods are variously known as private instance methods and private class methods. Private methods belong to a class's instance objects but are private: they can only be called by the instance methods defined by the class. Private methods are named with a leading #
.
Static fields are variously known as static properties or class properties. They are, by default, public and belong to the class, not instances of the class. You must use the class name or a reference to the class to access static fields; you can't use an instance object.
Static methods are sometimes called class methods. They are, by default, public and belong to the class, not instances of the class. You must use the class name or a reference to the class to call the method; you can't use an instance object.
In the next chapter, we'll explore prototypal inheritance and learn how classes relate to this fundamental aspect of JavaScript.
Rewrite the following Person
class to use private fields for the name
and age
properties and provide a setter for setting the age
property. Ensure that the setter raises a RangeError
unless the age is a positive number.
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
showAge() {
console.log(this.age);
}
}
let person = new Person('John', 30);
person.showAge(); // 30
person.age = 31;
person.showAge(); // 31
try {
// This line should raise a RangeError,
// but does not.
person.age = -5;
person.showAge(); // -5
} catch (e) {
//highlight
// The following line should run, but won't
console.log('RangeError: Age must be positive');
//endhighlight
}
class Person {
//highlight
#name;
#age;
//endhighlight
constructor(name, age) {
//highlight
this.#name = name;
this.age = age; // Call the setter to validate data
//endhighlight
}
//highlight
set age(age) {
if (typeof(age) === 'number' && age > 0) {
this.#age = age;
} else {
throw new RangeError('Age must be positive');
}
}
//endhighlight
showAge() {
//highlight
console.log(this.#age);
//endhighlight
}
}
let person = new Person('John', 30);
person.showAge(); // 30
person.age = 31;
person.showAge(); // 31
try {
person.age = -5;
//highlight
// The following line will not run
person.showAge();
//endhighlight
} catch (e) {
//highlight
// The following line will run
console.log('RangeError: Age must be positive');
//endhighlight
}
Create a Book
class with private fields title
, author
, and year
. Provide getters for each field and a setter for the year
field that raises a RangeError
if year
is before 1900
.
class Book {
// your code here
}
let book = new Book('The Great Gatsby', 'F. Scott Fitzgerald', 1925);
console.log(book.title); // The Great Gatsby
console.log(book.author); // F. Scott Fitzgerald
console.log(book.year); // 1925
book.year = 1932; // Changing year
console.log(book.year); // 1932
try {
book.year = 1825;
} catch (e) {
console.log(e); // RangeError: Invalid year
}
try {
let book2 = new Book('A Tale of Two Cities', 'Charles Dickents', 1859);
} catch (e) {
console.log(e); // RangeError: Invalid year
}
class Book {
//highlight
#title;
#author;
#year;
constructor(title, author, year) {
this.#title = title;
this.#author = author;
this.year = year;
}
get title() {
return this.#title;
}
get author() {
return this.#author;
}
get year() {
return this.#year;
}
set year(newYear) {
if (newYear < 1900) {
throw new RangeError('Invalid year');
} else {
this.#year = newYear;
}
}
//endhighlight
}
// test code omitted for brevity
Create a BankAccount
class with a private field balance
. Add a private method, #checkBalance
, that logs the current balance. Provide a public method, deposit
, to add money to the account and withdraw
to take money out. Raise a RangeError
if there are insufficient funds for the withdrawal.
class BankAccount {
// your code here
}
let account = new BankAccount();
account.deposit(100);
account.withdraw(50);
account.withdraw(100); // RangeError: Insufficient funds
class BankAccount {
//highlight
#balance = 0;
#checkBalance() {
console.log(`Current balance: $${this.#balance}`);
}
deposit(amount) {
this.#balance += amount;
this.#checkBalance();
}
withdraw(amount) {
if (amount > this.#balance) {
throw new RangeError('Insufficient funds');
} else {
this.#balance -= amount;
this.#checkBalance();
}
}
//endhighlight
}
let account = new BankAccount();
account.deposit(100); // Current balance: $100
account.withdraw(50); // Current balance: $50
account.withdraw(100); // Insufficient funds
Create a Rectangle
class with private fields width
and height
. Provide getters and setters for both fields. The setters should raise a RangeError
if the width or height is not a positive number. Add a getter for area
to compute the area of the rectangle (width * height).
class Rectangle {
// your code here
}
let rect = new Rectangle(10, 5);
console.log(rect.area); // 50
rect.width = 20;
console.log(rect.area); // 100
rect.height = 12;
console.log(rect.area); // 240
try {
rect.width = 0;
} catch (e) {
console.log(e); // RangeError: width must be positive
}
try {
rect.height = -10;
} catch (e) {
console.log(e); // RangeError: height must be positive
}
class Rectangle {
#width;
#height;
constructor(width, height) {
this.width = width;
this.height = height;
}
get width() {
return this.#width;
}
set width(value) {
if (value > 0) {
this.#width = value;
} else {
throw new RangeError('width must be positive');
}
}
get height() {
return this.#height;
}
set height(value) {
if (value > 0) {
this.#height = value;
} else {
throw new RangeError('height must be positive');
}
}
get area() {
return this.width * this.height;
}
}
// test code omitted for brevity
Create a MathUtils
class with static methods add
, subtract
, multiply
, and divide
. These methods should perform basic arithmetic operations.
class MathUtils {
// your code here
}
console.log(MathUtils.add(5, 3)); // 8
console.log(MathUtils.subtract(10, 4)); // 6
console.log(MathUtils.multiply(6, 7)); // 42
console.log(MathUtils.divide(20, 5)); // 4
console.log(MathUtils.divide(10, 0)); // RangeError: Division by zero
class MathUtils {
//highlight
static add(a, b) {
return a + b;
}
static subtract(a, b) {
return a - b;
}
static multiply(a, b) {
return a * b;
}
static divide(a, b) {
if (b === 0) {
throw new RangeError('Division by zero');
}
return a / b;
}
//endhighlight
}
console.log(MathUtils.add(5, 3)); // 8
console.log(MathUtils.subtract(10, 4)); // 6
console.log(MathUtils.multiply(6, 7)); // 42
console.log(MathUtils.divide(20, 5)); // 4
console.log(MathUtils.divide(10, 0)); // RangeError: Division by zero