Class basics
In this chapter, you learn the basics of how to write classes in TypeScript.
What you learn in this chapter
- How to define a class
- How to use the constructor
- Access modifiers (public, private, protected)
- The shorthand constructor syntax
- Getters and setters
You can try the code examples in this chapter on the TypeScript Playground.
What is a class
A class is a "blueprint" for objects. It lets you create objects with the same structure as many times as you like.
The difference between object literals and classes
// Object literal: create a single object directly
const person1 = {
name: 'Alice',
age: 25,
greet() {
console.log(`Hello, I'm ${this.name}`);
}
};
// To create an object with the same structure, you have to write it every time
const person2 = {
name: 'Bob',
age: 30,
greet() {
console.log(`Hello, I'm ${this.name}`);
}
};
With a class, you can easily create objects with the same structure.
// Class: a blueprint for objects
class Person {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
greet(): void {
console.log(`Hello, I'm ${this.name}`);
}
}
// Create instances from the class
const person1 = new Person('Alice', 25);
const person2 = new Person('Bob', 30);
person1.greet(); // 'Hello, I'm Alice'
person2.greet(); // 'Hello, I'm Bob'
Defining a class
Basic syntax
class ClassName {
// Property (field)
propertyName: type;
// Constructor
constructor(parameter: type) {
this.propertyName = parameter;
}
// Method
methodName(): returnType {
// Process
}
}
A concrete example: the User class
class User {
// Declare the properties
name: string;
email: string;
age: number;
// Constructor: called when an instance is created
constructor(name: string, email: string, age: number) {
// this refers to the instance that is about to be created
this.name = name;
this.email = email;
this.age = age;
}
// Define a method
introduce(): void {
console.log(`Name: ${this.name}, Email: ${this.email}, Age: ${this.age}`);
}
// A method with a return value
isAdult(): boolean {
return this.age >= 18;
}
}
// Create instances
const user1 = new User('Alice', 'alice@example.com', 25);
const user2 = new User('Bob', 'bob@example.com', 16);
// Call the methods
user1.introduce(); // 'Name: Alice, Email: alice@example.com, Age: 25'
console.log(user1.isAdult()); // true
console.log(user2.isAdult()); // false
Code walkthrough:
name: string;- the property's type declaration (required inside a class)constructor(...)- a special method that is called automatically when an instance is createdthis.name = name- this refers to the instance itselfnew User(...)- create an instance from the class
Initial values for properties
You can also set initial values for properties directly.
class Counter {
// Set initial values directly on the properties
count: number = 0;
step: number = 1;
increment(): void {
this.count += this.step;
}
getCount(): number {
return this.count;
}
}
const counter = new Counter();
console.log(counter.getCount()); // 0
counter.increment();
console.log(counter.getCount()); // 1
The constructor
The constructor is a special method that is called automatically when you create an instance with new.
class Product {
name: string;
price: number;
category: string;
constructor(name: string, price: number, category: string = 'general') {
this.name = name;
this.price = price;
this.category = category; // You can also set a default value
}
getInfo(): string {
return `${this.name}: ¥${this.price} (${this.category})`;
}
}
const product1 = new Product('Laptop', 100000, 'electronics');
const product2 = new Product('Notebook', 500); // category uses the default value
console.log(product1.getInfo()); // 'Laptop: ¥100000 (electronics)'
console.log(product2.getInfo()); // 'Notebook: ¥500 (general)'
Access modifiers
Access modifiers let you control the scope of access to properties and methods.
public (public)
This is the default modifier, and it is accessible from anywhere.
class User {
// public can be omitted (public by default)
public name: string;
public age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
public greet(): void {
console.log(`Hello, I'm ${this.name}`);
}
}
const user = new User('Alice', 25);
// Freely accessible from the outside
console.log(user.name); // 'Alice'
console.log(user.age); // 25
user.greet(); // 'Hello, I'm Alice'
// You can also change the properties
user.name = 'Alicia';
user.age = 26;
private (private)
Accessible only inside the class. It cannot be accessed from the outside or from derived classes.
class BankAccount {
public accountNumber: string;
private balance: number; // cannot be accessed from the outside
constructor(accountNumber: string, initialBalance: number) {
this.accountNumber = accountNumber;
this.balance = initialBalance;
}
// A public method to get the balance
public getBalance(): number {
return this.balance;
}
// Deposit
public deposit(amount: number): void {
if (amount > 0) {
this.balance += amount;
console.log(`Deposited ${amount}. New balance: ${this.balance}`);
}
}
// Withdraw
public withdraw(amount: number): boolean {
if (amount > 0 && amount <= this.balance) {
this.balance -= amount;
console.log(`Withdrawn ${amount}. New balance: ${this.balance}`);
return true;
}
console.log('Insufficient funds');
return false;
}
}
const account = new BankAccount('123-456', 1000);
// OK: public, so it is accessible
console.log(account.accountNumber); // '123-456'
// Error: private, so it cannot be accessed from the outside
// console.log(account.balance);
// Property 'balance' is private and only accessible within class 'BankAccount'
// OK: accessible only through a public method
console.log(account.getBalance()); // 1000
account.deposit(500); // 'Deposited 500. New balance: 1500'
account.withdraw(300); // 'Withdrawn 300. New balance: 1200'
Uses for private:
- Protect data that you do not want changed directly from the outside
- Hide the class's internal implementation (encapsulation)
- Keep data consistent
protected (protected)
Accessible inside the class and from derived classes. It cannot be accessed from the outside.
class Person {
protected name: string; // also accessible from derived classes
protected age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
protected getInfo(): string {
return `${this.name} (${this.age})`;
}
}
class Employee extends Person {
private employeeId: string;
constructor(name: string, age: number, employeeId: string) {
super(name, age); // call the parent class's constructor
this.employeeId = employeeId;
}
// The protected properties are accessible
public introduce(): void {
console.log(`I'm ${this.name}, ${this.age} years old. ID: ${this.employeeId}`);
}
}
const employee = new Employee('Alice', 30, 'E001');
employee.introduce(); // 'I'm Alice, 30 years old. ID: E001'
// Error: protected, so it cannot be accessed from the outside
// console.log(employee.name);
// console.log(employee.age);
ECMAScript Private Fields (the # property)
In addition to TypeScript's private modifier, you can also use the JavaScript-standard # prefix for private fields.
class BankAccount {
// ECMAScript Private Field (starts with #)
#balance: number;
accountNumber: string;
constructor(accountNumber: string, initialBalance: number) {
this.accountNumber = accountNumber;
this.#balance = initialBalance;
}
getBalance(): number {
return this.#balance;
}
deposit(amount: number): void {
if (amount > 0) {
this.#balance += amount;
}
}
}
const account = new BankAccount("123-456", 1000);
console.log(account.getBalance()); // 1000
// #balance cannot be accessed from the outside
// console.log(account.#balance);
// Error: Property '#balance' is not accessible outside class 'BankAccount'
// because it has a private identifier.
The difference between TypeScript's private and #:
| Feature | private | # (Private Field) |
|---|---|---|
| Access restriction | Compile time only | Enforced at runtime too |
| JavaScript output | A normal property | The # prefix is preserved |
| Access from a subclass | Not accessible | Not accessible |
| Forced access from outside | Possible with (obj as any).prop | Completely impossible |
// An example of the difference
class Example {
private tsPrivate = "TypeScript private";
#jsPrivate = "JavaScript private";
}
const ex = new Example();
// (ex as any).tsPrivate // "TypeScript private" (accessible at runtime)
// (ex as any).#jsPrivate // SyntaxError (not accessible at runtime either)
Prefer # when security matters or for library development. For ordinary application development, private is often enough.
readonly (read-only)
Defines a property that cannot be changed after initialization.
class Product {
public readonly id: number;
public readonly name: string;
public price: number; // can be changed
constructor(id: number, name: string, price: number) {
// Assignment is allowed inside the constructor
this.id = id;
this.name = name;
this.price = price;
}
updatePrice(newPrice: number): void {
this.price = newPrice; // OK: not readonly
}
}
const product = new Product(1, 'Laptop', 1000);
console.log(product.id); // 1
console.log(product.name); // 'Laptop'
console.log(product.price); // 1000
// OK: a normal property can be changed
product.price = 1200;
// Error: a readonly property cannot be changed
// product.id = 2;
// product.name = 'Desktop';
Comparison of access modifiers
| Modifier | Inside the class | Derived class | Outside |
|---|---|---|---|
| public | OK | OK | OK |
| protected | OK | OK | NG |
| private | OK | NG | NG |
| readonly | Initialization only | Read only | Read only |
The shorthand constructor syntax
In TypeScript, by adding an access modifier to a constructor argument, you can declare and initialize a property at the same time.
The traditional way
class User {
public name: string;
private age: number;
protected email: string;
constructor(name: string, age: number, email: string) {
this.name = name;
this.age = age;
this.email = email;
}
}
The shorthand syntax
class User {
// Just adding an access modifier to a constructor argument is enough
constructor(
public name: string,
private age: number,
protected email: string
) {
// The property is declared and initialized automatically
}
}
const user = new User('Alice', 25, 'alice@example.com');
console.log(user.name); // 'Alice'
How the shorthand syntax behaves:
// This single line does the following three things automatically
constructor(public name: string) {}
// The above is equivalent to:
// 1. Declare the property: public name: string;
// 2. Receive the parameter: constructor(name: string)
// 3. Initialize the property: this.name = name;
A practical example
class Product {
constructor(
public readonly id: number,
public name: string,
private _price: number,
public category: string = 'general' // You can also set a default value
) {}
get price(): number {
return this._price;
}
set price(value: number) {
if (value > 0) {
this._price = value;
} else {
throw new Error('Price must be positive');
}
}
}
const product = new Product(1, 'Laptop', 1000);
console.log(product.id); // 1
console.log(product.name); // 'Laptop'
console.log(product.price); // 1000
console.log(product.category); // 'general'
Getters and setters
Getters and setters are special features that look like properties but actually behave as methods.
Basic usage
class Person {
private _age: number = 0;
// Getter: can be read like a property
get age(): number {
return this._age;
}
// Setter: a value can be set like a property
set age(value: number) {
if (value >= 0 && value <= 150) {
this._age = value;
} else {
throw new Error('Invalid age');
}
}
}
const person = new Person();
// The getter is called (it is a method, but no () is needed)
console.log(person.age); // 0
// The setter is called
person.age = 25;
console.log(person.age); // 25
// The setter's validation kicks in
try {
person.age = -5; // an error is thrown
} catch (error) {
console.log((error as Error).message); // 'Invalid age'
}
Characteristics of getters/setters:
- They can be accessed like a property (no
()needed) - You can run logic when getting or setting a value
- Useful for validation, computation, formatting, and so on
Read-only properties (getter only)
class Rectangle {
constructor(
private width: number,
private height: number
) {}
// Getter only (no setter) = read-only
get area(): number {
return this.width * this.height;
}
get perimeter(): number {
return 2 * (this.width + this.height);
}
}
const rect = new Rectangle(10, 5);
console.log(rect.area); // 50
console.log(rect.perimeter); // 30
// area has a getter only, so it cannot be set
// rect.area = 100; // Error: Cannot assign to 'area' because it is a read-only property
A practical example: temperature conversion
class Temperature {
private _celsius: number = 0;
// Get/set Celsius
get celsius(): number {
return this._celsius;
}
set celsius(value: number) {
this._celsius = value;
}
// Get/set Fahrenheit (computed automatically)
get fahrenheit(): number {
return (this._celsius * 9/5) + 32;
}
set fahrenheit(value: number) {
this._celsius = (value - 32) * 5/9;
}
// Get Kelvin (read-only)
get kelvin(): number {
return this._celsius + 273.15;
}
}
const temp = new Temperature();
// Set in Celsius
temp.celsius = 100;
console.log(temp.celsius); // 100
console.log(temp.fahrenheit); // 212
console.log(temp.kelvin); // 373.15
// Set in Fahrenheit (internally converted to Celsius)
temp.fahrenheit = 32;
console.log(temp.celsius); // 0
console.log(temp.fahrenheit); // 32
console.log(temp.kelvin); // 273.15
The accessor keyword (TS 4.9+)
Introduced in TypeScript 4.9, the auto-accessor is a feature that reduces getter/setter boilerplate. A property marked with the accessor keyword automatically generates a private backing field plus a getter and setter.
class Person {
// The accessor keyword auto-generates a getter/setter
accessor name: string;
constructor(name: string) {
this.name = name;
}
}
// The above is equivalent to the following
class PersonExpanded {
#name: string;
constructor(name: string) {
this.#name = name;
}
get name(): string {
return this.#name;
}
set name(value: string) {
this.#name = value;
}
}
The main use of accessor is to insert logic into get/set in combination with decorators (decorators are covered in Chapter 10). When you just need a simple getter/setter, a public field is still enough.
Combining constants with the constructor (private readonly)
When you combine a constructor parameter property (the shorthand syntax) with readonly, you can declare a private field that cannot be changed from the outside in the shortest way. This is a pattern that shows up often when you do DI (dependency injection) or build an immutable value object.
class UserService {
// The private readonly combination: cannot be changed after initialization, and invisible from the outside
constructor(
private readonly db: Database,
private readonly logger: Logger,
) {}
async getUser(id: number): Promise<User | null> {
this.logger.info(`Fetching user ${id}`);
return this.db.users.findById(id);
}
}
// The caller
const service = new UserService(db, logger);
// service.db = otherDb; // Error: db is readonly
Note that readonly only forbids reassignment after initialization; it does not guarantee deep immutability. The internals of arrays and objects can still be changed (when you want fully immutable data, combine it with Readonly<T> / as const).
Try it: build a User class ★★
Create a User class that meets the following requirements.
Requirements:
-
Properties:
id(readonly, number): the user IDname(public, string): the usernameemail(private, string): the email address_password(private, string): the password (assume it is already hashed)createdAt(readonly, Date): the creation time
-
Constructor: receives id, name, email, and password (createdAt is set automatically)
-
Methods:
getEmail(): returns the email addressupdateEmail(newEmail): updates the email addresscheckPassword(input): checks whether the password matchesgetAccountAge(): returns the number of days since account creation
-
Getter/setter:
displayName: returns the name with the "-san" honorific appendedpassword: settable only (cannot be read)
Hint
- The shorthand constructor syntax lets you write it concisely
- Initialize
createdAtwithnew Date()inside the constructor - In the password setter, you would normally hash the value (omitted in this exercise)
- Compute the account age with
Date.now() - this.createdAt.getTime()
Answer and explanation
class User {
// Use the shorthand constructor syntax
public readonly createdAt: Date;
constructor(
public readonly id: number,
public name: string,
private email: string,
private _password: string
) {
// createdAt is not received as an argument; set the current time automatically
this.createdAt = new Date();
}
// Get the email address
getEmail(): string {
return this.email;
}
// Update the email address
updateEmail(newEmail: string): void {
// A simple validation
if (!newEmail.includes('@')) {
throw new Error('Invalid email format');
}
this.email = newEmail;
console.log('Email updated successfully');
}
// Check whether the password matches
checkPassword(input: string): boolean {
// In reality, you would hash it and compare
return this._password === input;
}
// Return the number of days since account creation
getAccountAge(): number {
const now = Date.now();
const created = this.createdAt.getTime();
const diffMs = now - created;
// Convert milliseconds to days
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
}
// A getter that returns the name with the "-san" honorific appended
get displayName(): string {
return `${this.name}-san`;
}
// The password is settable only (cannot be read)
set password(newPassword: string) {
// In reality, you would hash it here
if (newPassword.length < 8) {
throw new Error('Password must be at least 8 characters');
}
this._password = newPassword;
console.log('Password updated successfully');
}
}
// Test
const user = new User(1, 'Alice', 'alice@example.com', 'password123');
// Access the properties
console.log(user.id); // 1
console.log(user.name); // 'Alice'
console.log(user.displayName); // 'Alice-san'
console.log(user.createdAt); // the current time
// Call the methods
console.log(user.getEmail()); // 'alice@example.com'
console.log(user.checkPassword('password123')); // true
console.log(user.checkPassword('wrongpass')); // false
console.log(user.getAccountAge()); // 0 (just after creation)
// Update the email address
user.updateEmail('alice.new@example.com');
console.log(user.getEmail()); // 'alice.new@example.com'
// Update the password (through the setter)
user.password = 'newpassword123';
console.log(user.checkPassword('newpassword123')); // true
// Error cases
try {
user.password = 'short'; // fewer than 8 characters is an error
} catch (error) {
console.log((error as Error).message); // 'Password must be at least 8 characters'
}
try {
user.updateEmail('invalid-email'); // missing @ is an error
} catch (error) {
console.log((error as Error).message); // 'Invalid email format'
}
Explanation:
readonlymakes id and createdAt immutableprivatehides email and _password from the outside- The
displayNamegetter returns a transformed value - The
passwordsetter inserts validation - The methods encapsulate getting and updating data
Summary
What you learned in this chapter:
- Defining a class: a blueprint for objects with properties, a constructor, and methods
- The constructor: an initialization method called when an instance is created
- Access modifiers: public (public), private (private), protected (public down to derived classes), readonly (read-only)
- The shorthand constructor syntax: add an access modifier to an argument to declare and initialize at the same time
- Getters and setters: methods you can access like properties
In the next chapter, you will learn about class inheritance and interfaces. We will cover more advanced concepts of object-oriented programming.
Common pitfalls for beginners
Common mistakes
❌ Forgetting to initialize a property
// ❌ Wrong: the property is not initialized
class User {
name: string; // Error: Property 'name' has no initializer
age: number;
}
// ✅ Correct approach 1: initialize in the constructor
class User {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
// ✅ Correct approach 2: set an initial value
class User {
name: string = '';
age: number = 0;
}
// ✅ Correct approach 3: the shorthand constructor syntax
class User {
constructor(public name: string, public age: number) {}
}
Cause: When strictPropertyInitialization is enabled, a property must be initialized at declaration or in the constructor.
Solution: Use an initial value, initialize in the constructor, or use the shorthand syntax.
❌ Confusing private with readonly
class Config {
private apiKey: string; // cannot be accessed from the outside, but can be changed internally
readonly version: string; // accessible from the outside, but cannot be changed
constructor(apiKey: string, version: string) {
this.apiKey = apiKey;
this.version = version;
}
rotateKey(newKey: string) {
this.apiKey = newKey; // OK: private can be changed internally
// this.version = '2.0'; // Error: readonly cannot be changed
}
}
const config = new Config('secret', '1.0');
// console.log(config.apiKey); // Error: private is not accessible
console.log(config.version); // OK: readonly can be read
Cause: private restricts access, while readonly restricts changes — they have different purposes.
Solution: Use private to forbid access from the outside, and readonly to forbid changes.
❌ Forgetting the access modifier in the shorthand constructor syntax
// ❌ Wrong: without an access modifier, no property is created
class User {
constructor(name: string, age: number) {
// this.name = name; // Error: the 'name' property does not exist
}
}
// ✅ Correct: adding an access modifier creates the property
class User {
constructor(public name: string, public age: number) {
// The property is declared and initialized automatically
}
}
Cause: The shorthand syntax only works when there is an access modifier (public, private, protected, readonly).
Solution: Always add an access modifier when you use the shorthand syntax.
❌ Thinking a getter-only property can also be set
class Rectangle {
constructor(private _width: number, private _height: number) {}
// Getter only
get area(): number {
return this._width * this._height;
}
}
const rect = new Rectangle(10, 5);
console.log(rect.area); // 50
// ❌ Wrong: trying to assign to a property that has no setter
rect.area = 100; // Error: Cannot assign to 'area' because it is a read-only property
Cause: A property with a getter only and no setter is read-only.
Solution: Define a setter too if you want it to be changeable. Computed values usually do not have a setter.
❌ Forgetting this when accessing a property
class Counter {
count: number = 0;
// ❌ Wrong: this is forgotten
incrementWrong() {
count++; // Error: the name 'count' does not exist
}
// ✅ Correct: use this to access the property
incrementCorrect() {
this.count++;
}
}
Cause: You need this to access a property inside a class.
Solution: Always use this. when accessing properties or methods inside a class.