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
// ❌ 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
// ✅ 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
// ❌ 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
// ✅ 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
// ❌ 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
// ✅ 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.
// ❌ 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 { }
// ✅ 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)
// ❌ 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)
// ✅ 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)
// ❌ 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)
// ✅ 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:
// ✅ 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:
- Each class has a single responsibility
- Make properties private to hide information
- Restrict the delivery area with a type
- An API design that does not depend on internal implementation
Summary of 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
What to read next
- TypeScript Guide: Class basics — a systematic explanation of the language spec (access modifiers / abstract / static)
- Same: Class inheritance — inheritance syntax and OOP patterns