Skip to main content

Classes and interfaces

Let's learn how to define classes and interfaces in TypeScript, along with best practices.

Key points

  • Hide a class's information as much as possible
  • Make a class usable without the caller knowing its internals
  • Keep classes as small as possible
  • Know the difference between class inheritance and composition
  • Be mindful of cohesion and coupling

Keep classes as small as possible

A class that is too large may violate the single responsibility principle. Split it appropriately.

Bad

❌ Bad: A class with multiple responsibilities
// ❌ A class with multiple responsibilities
class Cart {
private items: CartItem[] = [];

addProduct(product: Product): void { /* ... */ }
getProductPrice(productId: string): number { /* ... */ }
getProductCoupon(productId: string): Coupon | null { /* ... */ }
calculate(): number { /* ... */ }
printReceipt(): void { /* ... */ }
}

Good

✅ Good: Separate the responsibilities
// ✅ Separate the responsibilities
class Product {
constructor(
private readonly id: string,
private readonly price: number,
private readonly coupon: Coupon | null = null
) {}

getPrice(): number {
return this.price;
}

getCoupon(): Coupon | null {
return this.coupon;
}
}

class Cart {
private items: Product[] = [];

constructor(initialItems: Product[] = []) {
this.items = initialItems;
}

add(product: Product): void {
this.items.push(product);
}

calculate(): number {
return this.items.reduce((total, item) => total + item.getPrice(), 0);
}
}

class ReceiptPrinter {
print(cart: Cart): void {
// Receipt printing process
}
}

Hide a class's internal information as much as possible

In TypeScript you can hide information with the private, protected, and readonly modifiers.

Bad

❌ Bad: Internal state is exposed
// ❌ Internal state is exposed
class Television {
modelNumber: string = 'Model Number';

displayModelNumber(): void {
console.log(this.modelNumber);
}
}

const tv = new Television();
tv.modelNumber = 'A value that causes a bug'; // Can be changed from outside

Good

✅ Good: Protect with private and readonly
// ✅ Protect with private and readonly
class Television {
private readonly modelNumber: string = 'Model Number';

displayModelNumber(): void {
console.log(this.modelNumber);
}

getModelNumber(): string {
return this.modelNumber;
}
}

const tv = new Television();
// tv.modelNumber = 'Changed'; // Compile error
tv.displayModelNumber(); // The correct way to use it

Private fields (#)

In TypeScript you can also use JavaScript private fields (#).

class Circle {
// Fields starting with # are private at runtime as well
#radius: number;
readonly #PI = Math.PI;

constructor(radius: number) {
this.#radius = radius;
}

getArea(): number {
return this.#PI * this.#radius ** 2;
}

getPerimeter(): number {
return 2 * this.#PI * this.#radius;
}

// Control access with a getter/setter
get radius(): number {
return this.#radius;
}

set radius(newRadius: number) {
if (newRadius <= 0) {
throw new Error('Radius must be positive');
}
this.#radius = newRadius;
}
}

Make a class usable without the caller knowing its internals

By making a class usable without knowing its internal structure, you can provide an easy-to-use API.

Bad

❌ Bad: You need to know the internal structure
// ❌ You need to know the internal structure
class Television {
power: boolean = true;
switch: Switch = new Switch();
}

class Switch {
toggle(power: boolean): boolean {
return !power;
}
}

const tv = new Television();
// The user needs to know that Switch.toggle exists
tv.power = tv.switch.toggle(tv.power);

Good

✅ Good: Provide a simple API
// ✅ Provide a simple API
class Television {
private power: boolean = false;
private readonly powerSwitch: Switch = new Switch();

togglePower(): void {
this.power = this.powerSwitch.toggle(this.power);
console.log(`Power: ${this.power ? 'ON' : 'OFF'}`);
}

isPoweredOn(): boolean {
return this.power;
}
}

class Switch {
toggle(state: boolean): boolean {
return !state;
}
}

const tv = new Television();
tv.togglePower(); // Usable without knowing the internal implementation

Class inheritance and composition

Inheritance (is-a relationship)

Use it when there is an "A is a B" relationship.

// The "Dog is an Animal" relationship
abstract class Animal {
abstract makeSound(): void;

eat(): void {
console.log('Eat');
}

sleep(): void {
console.log('Sleep');
}
}

class Dog extends Animal {
makeSound(): void {
console.log('Woof!');
}

sleep(): void {
console.log('Sleep in the kennel'); // Override
}
}

const dog = new Dog();
dog.eat(); // Eat (inherited)
dog.sleep(); // Sleep in the kennel (overridden)
dog.makeSound(); // Woof!

Composition (has-a relationship)

Use it when there is an "A has a B" relationship.

// The "Television has a Switch" relationship
class Switch {
toggle(state: boolean): boolean {
return !state;
}
}

class Television {
private power: boolean = false;
private readonly powerSwitch: Switch; // Composition

constructor() {
this.powerSwitch = new Switch();
}

togglePower(): void {
this.power = this.powerSwitch.toggle(this.power);
}
}

Prefer composition over inheritance

Inheritance creates strong coupling, so composition is often more flexible.

❌ Bad: Inheritance — the 'Hero/Enemy is an Attack' relationship is unnatural
// ❌ Inheritance — the "Hero/Enemy is an Attack" relationship is unnatural
class Attack {
punch(target: string, damage: number): void {
console.log(`Dealt ${damage} damage to ${target}`);
}
}

class Hero extends Attack { }
class Enemy extends Attack { }
✅ Good: Composition — Hero/Enemy has an Attack
// ✅ Composition — Hero/Enemy has an Attack
interface AttackAction {
execute(target: string): void;
}

class PunchAttack implements AttackAction {
constructor(private damage: number) {}

execute(target: string): void {
console.log(`Dealt ${this.damage} damage to ${target}`);
}
}

class Hero {
constructor(private attack: AttackAction) {}

performAttack(target: string): void {
this.attack.execute(target);
}
}

// The attack method can be changed flexibly
const hero = new Hero(new PunchAttack(10));
hero.performAttack('Slime');

Use interfaces

TypeScript interfaces are ideal for defining a class's contract.

// Define a contract with an interface
interface PaymentMethod {
processPayment(amount: number): Promise<PaymentResult>;
refund(transactionId: string): Promise<RefundResult>;
}

// Provide multiple implementations
class CreditCardPayment implements PaymentMethod {
async processPayment(amount: number): Promise<PaymentResult> {
// Credit card payment process
return { success: true, transactionId: 'cc_123' };
}

async refund(transactionId: string): Promise<RefundResult> {
// Refund process
return { success: true };
}
}

class PayPalPayment implements PaymentMethod {
async processPayment(amount: number): Promise<PaymentResult> {
// PayPal payment process
return { success: true, transactionId: 'pp_456' };
}

async refund(transactionId: string): Promise<RefundResult> {
// Refund process
return { success: true };
}
}

// The caller depends on the interface
class PaymentProcessor {
constructor(private paymentMethod: PaymentMethod) {}

async checkout(amount: number): Promise<PaymentResult> {
return this.paymentMethod.processPayment(amount);
}
}

Increase cohesion

Cohesion is a measure of how strongly the data and logic within a module are related.

Bad (low cohesion)

❌ Bad: Data and logic are scattered
// ❌ Data and logic are scattered
class Product {
price: number = 0;
}

class Shop {
getTaxFreePrice(product: Product): number {
return product.price;
}

getTaxIncludedPrice(product: Product): number {
return product.price * 1.1;
}
}

class OnlineShop {
// Reimplements the same logic...
getTaxFreePrice(product: Product): number {
return product.price;
}

getTaxIncludedPrice(product: Product): number {
return product.price * 1.1;
}
}

Good (high cohesion)

✅ Good: Keep data and logic together in one place
// ✅ Keep data and logic together in one place
class Product {
private readonly TAX_RATE = 0.1;

constructor(private readonly price: number) {}

getTaxFreePrice(): number {
return this.price;
}

getTaxIncludedPrice(): number {
return this.price * (1 + this.TAX_RATE);
}
}

// Shop and OnlineShop just use Product's methods
class Shop {
checkout(products: Product[]): number {
return products.reduce((total, p) => total + p.getTaxIncludedPrice(), 0);
}
}

Lower coupling (loose coupling)

Coupling is a measure of how much modules depend on each other.

Bad (tight coupling)

❌ Bad: Accessing internal properties directly
// ❌ Accessing internal properties directly
class Product {
price: number = 0;
TAX: number = 0.1;
discountPercent: number = 0.2;

constructor(price: number) {
this.price = price;
}
}

class Order {
getTaxIncludedTotalPrice(): number {
const product = new Product(3000);
// Depends on Product's internal structure
return product.price * (1 + product.TAX) * (1 - product.discountPercent);
}
}

Good (loose coupling)

✅ Good: Interact through an interface
// ✅ Interact through an interface
class Product {
private readonly price: number;
private readonly TAX = 0.1;
private readonly discountPercent = 0.2;

constructor(price: number) {
this.price = price;
}

getTaxIncludedPrice(): number {
return this.price * (1 + this.TAX) * (1 - this.discountPercent);
}
}

class Order {
constructor(private readonly products: Product[]) {}

getTaxIncludedTotalPrice(): number {
return this.products.reduce(
(total, product) => total + product.getTaxIncludedPrice(),
0
);
}
}

Use abstract classes

TypeScript abstract classes can hold a shared implementation while forcing subclasses to implement specific methods.

abstract class Character {
constructor(protected name: string) {}

getName(): string {
return this.name;
}

// Force subclasses to implement this
abstract startLesson(): void;
}

class Teacher extends Character {
constructor(
name: string,
private subject: string
) {
super(name);
}

startLesson(): void {
console.log(`${this.getName()}: Let's start ${this.subject}`);
}
}

class Student extends Character {
startLesson(): void {
console.log(`Hello! I'm ${this.getName()}.`);
}
}

Exercises

Refactor the following code to be highly cohesive and loosely coupled
// Problem
class Delivery {
area = {
hokkaido: 1000,
tohoku: 600,
kanto: 600,
};
}

class Item {
name: string;
price: number;
quantity: number;

constructor(name: string, price: number, quantity: number) {
this.name = name;
this.price = price;
this.quantity = quantity;
}
}

class Order {
delivery = new Delivery();
items: Item[];
deliveryArea: string;

constructor() {
this.items = [
new Item('apple', 100, 1),
new Item('orange', 200, 2),
];
this.deliveryArea = 'hokkaido';
}

getTotalPrice(): number {
const itemTotal = this.items.reduce(
(total, item) => total + item.price * item.quantity,
0
);
return itemTotal + this.delivery.area[this.deliveryArea as keyof typeof this.delivery.area];
}
}

Answer:

✅ Good: After refactoring
// ✅ After refactoring
type DeliveryArea = 'hokkaido' | 'tohoku' | 'kanto';

class Delivery {
private static readonly FEES: Record<DeliveryArea, number> = {
hokkaido: 1000,
tohoku: 600,
kanto: 600,
};

constructor(private destination: DeliveryArea) {}

getFee(): number {
return Delivery.FEES[this.destination];
}

changeDestination(destination: DeliveryArea): void {
this.destination = destination;
}
}

class Item {
constructor(
private readonly name: string,
private readonly price: number,
private readonly quantity: number
) {}

getName(): string {
return this.name;
}

getSubtotal(): number {
return this.price * this.quantity;
}
}

class Cart {
constructor(private items: Item[] = []) {}

add(item: Item): void {
this.items.push(item);
}

remove(itemName: string): void {
this.items = this.items.filter(item => item.getName() !== itemName);
}

getItemTotal(): number {
return this.items.reduce((total, item) => total + item.getSubtotal(), 0);
}

getItems(): Item[] {
return [...this.items];
}
}

class Order {
private readonly delivery: Delivery;

constructor(
private readonly cart: Cart,
destination: DeliveryArea
) {
this.delivery = new Delivery(destination);
}

getTotalPrice(): number {
return this.cart.getItemTotal() + this.delivery.getFee();
}

changeDestination(destination: DeliveryArea): void {
this.delivery.changeDestination(destination);
}
}

// Usage example
const cart = new Cart([
new Item('apple', 100, 1),
new Item('orange', 200, 2),
]);
const order = new Order(cart, 'hokkaido');
console.log(order.getTotalPrice()); // 1500

Improvements:

  1. Each class has a single responsibility
  2. Make properties private to hide information
  3. Restrict the delivery area with a type
  4. An API design that does not depend on internal implementation

Summary of classes and interfaces

Best practices for classes and interfaces
  • Keep classes small with a single responsibility
  • Hide information with private / readonly
  • Provide an easy-to-use API
  • is-a means inheritance, has-a means composition
  • Prefer composition over inheritance
  • Define contracts with interfaces
  • Aim for high cohesion and loose coupling
  • Provide a shared implementation with abstract classes