In the previous tutorial, we learned about generics. Now let’s learn about classes in TypeScript — how they work, what access modifiers do, and when you should use them.
By the end of this tutorial, you will know how to use public, private, protected, readonly, abstract classes, parameter properties, and how to implement interfaces with classes.
Basic Classes
A class defines a blueprint for objects. TypeScript adds type annotations to JavaScript classes:
class User {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
greet(): string {
return `Hi, I'm ${this.name} and I'm ${this.age} years old.`;
}
}
const user = new User("Alex", 25);
console.log(user.greet()); // "Hi, I'm Alex and I'm 25 years old."
console.log(user.name); // "Alex"
You must declare properties and their types before using them. TypeScript enforces that all properties are assigned in the constructor (when strictPropertyInitialization is enabled).
Access Modifiers
TypeScript has three access modifiers that control who can access a property or method:
public (Default)
Every property and method is public by default. Anyone can access it:
class User {
public name: string; // public is optional — it's the default
constructor(name: string) {
this.name = name;
}
public greet(): string {
return `Hi, I'm ${this.name}`;
}
}
const user = new User("Alex");
console.log(user.name); // OK
console.log(user.greet()); // OK
You can write public explicitly for clarity, but most developers leave it out.
private
A private property or method can only be accessed inside the class:
class BankAccount {
private balance: number;
constructor(initialBalance: number) {
this.balance = initialBalance;
}
deposit(amount: number): void {
if (amount <= 0) {
throw new Error("Amount must be positive");
}
this.balance += amount;
}
withdraw(amount: number): void {
if (amount > this.balance) {
throw new Error("Insufficient funds");
}
this.balance -= amount;
}
getBalance(): number {
return this.balance;
}
}
const account = new BankAccount(1000);
account.deposit(500);
console.log(account.getBalance()); // 1500
// account.balance; // Error: Property 'balance' is private
Note: private is a compile-time check only. At runtime, JavaScript does not enforce it. If you need runtime privacy, use JavaScript’s # private fields:
class BankAccount {
#balance: number; // Runtime private
constructor(initialBalance: number) {
this.#balance = initialBalance;
}
getBalance(): number {
return this.#balance;
}
}
protected
A protected property or method can be accessed inside the class and in subclasses:
class Animal {
protected name: string;
constructor(name: string) {
this.name = name;
}
protected makeSound(): string {
return "...";
}
}
class Dog extends Animal {
breed: string;
constructor(name: string, breed: string) {
super(name);
this.breed = breed;
}
describe(): string {
// Can access protected members from parent
return `${this.name} is a ${this.breed} that says ${this.makeSound()}`;
}
protected makeSound(): string {
return "Woof!";
}
}
const dog = new Dog("Rex", "German Shepherd");
console.log(dog.describe()); // "Rex is a German Shepherd that says Woof!"
// dog.name; // Error: Property 'name' is protected
// dog.makeSound(); // Error: Property 'makeSound' is protected
Access Modifier Summary
| Modifier | Inside Class | Subclass | Outside |
|---|---|---|---|
public | Yes | Yes | Yes |
protected | Yes | Yes | No |
private | Yes | No | No |
readonly Properties
Use readonly to prevent a property from being changed after initialization:
class User {
readonly id: number;
name: string;
constructor(id: number, name: string) {
this.id = id;
this.name = name;
}
}
const user = new User(1, "Alex");
user.name = "Sam"; // OK
user.id = 2; // Error: Cannot assign to 'id' because it is a read-only property
You can combine readonly with access modifiers:
class Config {
private readonly apiKey: string;
constructor(apiKey: string) {
this.apiKey = apiKey;
}
getApiKey(): string {
return this.apiKey;
}
}
Parameter Properties
Writing properties, then assigning them in the constructor is repetitive. TypeScript has a shorthand called parameter properties:
// Before — verbose
class User {
name: string;
age: number;
email: string;
constructor(name: string, age: number, email: string) {
this.name = name;
this.age = age;
this.email = email;
}
}
// After — parameter properties
class User {
constructor(
public name: string,
public age: number,
public email: string,
) {}
}
Adding an access modifier (public, private, protected) or readonly to a constructor parameter automatically:
- Declares the property
- Assigns the value
You can mix parameter properties with regular parameters:
class User {
constructor(
public readonly id: number,
public name: string,
private password: string,
) {}
checkPassword(input: string): boolean {
return this.password === input;
}
}
const user = new User(1, "Alex", "secret123");
console.log(user.name); // "Alex"
console.log(user.checkPassword("secret123")); // true
// user.password; // Error: private
// user.id = 2; // Error: readonly
Getters and Setters
Use get and set to control how a property is read and written:
class Temperature {
private _celsius: number;
constructor(celsius: number) {
this._celsius = 0; // Initialize before using setter
this.celsius = celsius; // Use setter to validate
}
get celsius(): number {
return this._celsius;
}
set celsius(value: number) {
if (value < -273.15) {
throw new Error("Temperature below absolute zero");
}
this._celsius = value;
}
get fahrenheit(): number {
return this._celsius * 9 / 5 + 32;
}
set fahrenheit(value: number) {
this.celsius = (value - 32) * 5 / 9;
}
}
const temp = new Temperature(100);
console.log(temp.celsius); // 100
console.log(temp.fahrenheit); // 212
temp.fahrenheit = 32;
console.log(temp.celsius); // 0
Getters and setters look like regular properties when you use them. The get/set logic runs behind the scenes.
Static Members
static members belong to the class itself, not to instances:
class MathHelper {
static PI = 3.14159;
static circleArea(radius: number): number {
return MathHelper.PI * radius ** 2;
}
static clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
}
console.log(MathHelper.PI); // 3.14159
console.log(MathHelper.circleArea(5)); // 78.54
console.log(MathHelper.clamp(15, 0, 10)); // 10
You access static members through the class name, not through an instance. Static members are useful for utility functions and shared constants.
Abstract Classes
An abstract class cannot be instantiated directly. It serves as a base class that other classes must extend:
abstract class Shape {
abstract getArea(): number;
abstract getPerimeter(): number;
describe(): string {
return `Area: ${this.getArea().toFixed(2)}, Perimeter: ${this.getPerimeter().toFixed(2)}`;
}
}
class Circle extends Shape {
constructor(private radius: number) {
super();
}
getArea(): number {
return Math.PI * this.radius ** 2;
}
getPerimeter(): number {
return 2 * Math.PI * this.radius;
}
}
class Rectangle extends Shape {
constructor(
private width: number,
private height: number,
) {
super();
}
getArea(): number {
return this.width * this.height;
}
getPerimeter(): number {
return 2 * (this.width + this.height);
}
}
// const shape = new Shape(); // Error: Cannot create an instance of an abstract class
const circle = new Circle(5);
console.log(circle.describe()); // "Area: 78.54, Perimeter: 31.42"
const rect = new Rectangle(10, 5);
console.log(rect.describe()); // "Area: 50.00, Perimeter: 30.00"
Abstract classes can have:
- Abstract methods — declared but not implemented. Subclasses must implement them.
- Regular methods — have implementation. Subclasses inherit them.
Implementing Interfaces
Classes can implement interfaces with the implements keyword:
interface Serializable {
serialize(): string;
}
interface Loggable {
log(): void;
}
class User implements Serializable, Loggable {
constructor(
public name: string,
public age: number,
) {}
serialize(): string {
return JSON.stringify({ name: this.name, age: this.age });
}
log(): void {
console.log(`User: ${this.name}, Age: ${this.age}`);
}
}
const user = new User("Alex", 25);
user.log(); // "User: Alex, Age: 25"
console.log(user.serialize()); // '{"name":"Alex","age":25}'
A class can implement multiple interfaces. The class must provide all methods defined in the interfaces.
Abstract Class vs Interface
| Feature | Abstract Class | Interface |
|---|---|---|
| Implementation | Can have | Cannot (only declarations) |
| Constructor | Yes | No |
| Access modifiers | Yes | No |
| Multiple inheritance | No (single extends) | Yes (multiple implements) |
| Runtime existence | Yes | No (erased) |
When to use each:
- Interface — when you just need a contract (shape). Most common choice.
- Abstract class — when you need shared implementation that subclasses inherit.
Classes vs Plain Objects and Functions
Modern TypeScript often prefers plain objects and functions over classes. Here is why:
// Class approach
class UserService {
private apiUrl: string;
constructor(apiUrl: string) {
this.apiUrl = apiUrl;
}
async getUser(id: number): Promise<{ name: string }> {
const response = await fetch(`${this.apiUrl}/users/${id}`);
return response.json();
}
}
// Function approach — simpler
function createUserService(apiUrl: string) {
return {
getUser: async (id: number): Promise<{ name: string }> => {
const response = await fetch(`${apiUrl}/users/${id}`);
return response.json();
},
};
}
When to use classes:
- You need inheritance (extend, abstract)
- You are working with a framework that expects classes (Angular, NestJS)
- You need
instanceofchecks - Complex objects with many methods and internal state
When to use functions and objects:
- Simple data transformations
- Stateless utilities
- Functional programming patterns
- React components and hooks
Most React and Node.js code uses functions. Classes are more common in Angular and backend frameworks like NestJS.
A Note on Decorators
TypeScript 5.0+ supports standard ECMAScript decorators. Decorators let you add behavior to classes and their members using a special @ syntax:
function log(target: any, context: ClassMethodDecoratorContext) {
const methodName = String(context.name);
return function (this: any, ...args: any[]) {
console.log(`Calling ${methodName} with`, args);
return (target as Function).apply(this, args);
};
}
class Calculator {
@log
add(a: number, b: number): number {
return a + b;
}
}
const calc = new Calculator();
calc.add(2, 3); // Logs: "Calling add with [2, 3]" then returns 5
Decorators are heavily used by frameworks like NestJS, Angular, and TypeORM. For most TypeScript code, you do not need decorators. We mention them here so you recognize the syntax when you see it.
Quick Practice
Create src/classes.ts and try these:
// Abstract class with parameter properties
abstract class Vehicle {
constructor(
public readonly brand: string,
protected speed: number = 0,
) {}
abstract maxSpeed(): number;
accelerate(amount: number): void {
this.speed = Math.min(this.speed + amount, this.maxSpeed());
}
brake(amount: number): void {
this.speed = Math.max(this.speed - amount, 0);
}
getStatus(): string {
return `${this.brand}: ${this.speed} km/h`;
}
}
class Car extends Vehicle {
constructor(
brand: string,
private _maxSpeed: number,
) {
super(brand);
}
maxSpeed(): number {
return this._maxSpeed;
}
}
class ElectricCar extends Car {
constructor(
brand: string,
maxSpeed: number,
private batteryLevel: number = 100,
) {
super(brand, maxSpeed);
}
getStatus(): string {
return `${super.getStatus()} | Battery: ${this.batteryLevel}%`;
}
}
// Implementing interfaces
interface HasId {
readonly id: number;
}
interface Printable {
print(): void;
}
class Product implements HasId, Printable {
constructor(
public readonly id: number,
public name: string,
public price: number,
) {}
print(): void {
console.log(`#${this.id} ${this.name} — $${this.price}`);
}
}
// Test it
const car = new Car("Toyota", 180);
car.accelerate(100);
console.log(car.getStatus()); // "Toyota: 100 km/h"
const tesla = new ElectricCar("Tesla", 250, 85);
tesla.accelerate(200);
console.log(tesla.getStatus()); // "Tesla: 200 km/h | Battery: 85%"
const product = new Product(1, "Laptop", 999);
product.print(); // "#1 Laptop — $999"
Run it:
npx tsx src/classes.ts
Output:
Toyota: 100 km/h
Tesla: 200 km/h | Battery: 85%
#1 Laptop — $999
What is Next?
In the next tutorial, we will learn about modules and namespaces — how to organize your TypeScript code across multiple files with import and export.
Related Articles
- TypeScript Tutorial #9: Generics — previous tutorial in this series
- Go Tutorial: HTTP Servers — how Go structures code without classes