How to buildOctober 11, 20267 min read
JavaStep 2 of 6

OOP: classes, encapsulation, inheritance and composition

In short

Object-oriented programming (OOP) combines data and behavior into objects: an order knows its own contents and the rules for changing them. A class describes such objects, # private fields hide state, inheritance reuses shared logic through extends and super, and composition assembles behavior from small independent objects. In practice, inheritance suits small, stable hierarchies, and composition suits almost everything else.

FundamentalsLevel: IntermediateObject-Oriented Programming

After this you will be able to

  • Write a class with # private fields, getters and methods that protect invariants
  • Explain the difference between TypeScript's private and JavaScript's #
  • Build polymorphism with an interface and inheritance with super
  • Explain when to prefer composition over inheritance and assemble behavior from small objects

Verified with

Node.js 24.18 LTSTypeScript 7.0Java 21 LTS

Checked against docs: October 11, 2026

Contents
  1. 01An object as data plus behavior
  2. 02Classes in TypeScript: constructor, methods, #private
  3. 03Encapsulation: invariants and getters
  4. 04Inheritance and super
  5. 05Polymorphism with payment methods
  6. 06Composition over inheritance
  7. 07What it looks like in Java

An object as data plus behavior

In the data structures lesson, an order was a plain object { id, status, total }, and the rules for working with it lived in separate functions. That is fine while there are few rules. But once you have conditions like "a paid order cannot change" or "quantity must be a positive whole number", they have to be checked everywhere the order changes — and sooner or later someone forgets.

OOP suggests keeping data and rules together. A class is a template for creating objects; an object created with new is called an instance. Outside code works with an instance only through its methods, and the methods make sure the data never reaches an invalid state.

  • Encapsulation — state is hidden and can only change through methods with checks
  • Inheritance — a child class gets the parent's fields and methods and can extend them
  • Polymorphism — different objects fulfil one contract, and code works with any of them
  • Composition — an object contains other objects and delegates part of the work to them

The examples are written in TypeScript and run directly in Node.js 24. Since Node.js 24.12, type stripping is stable: Node.js replaces annotations with whitespace and runs the code, but does not check types. So type checking is a separate step — the tsc compiler with the --noEmit flag. The Node.js documentation recommends the erasableSyntaxOnly and verbatimModuleSyntax settings for this mode: they forbid TypeScript constructs that Node.js cannot simply erase, such as enum and constructor parameter properties.

bash
npm init -y
npm pkg set type=module
npm install --save-dev typescript@7.0.2 @types/node@24
npx tsc --noEmit
node order-demo.ts
Project folder setup: TypeScript for type checking, Node.js itself for running.
tsconfig.json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true,
    "noImplicitOverride": true,
    "types": ["node"]
  }
}

Classes in TypeScript: constructor, methods, #private

Let's model a café order. It has a number that never changes, a list of items and a status. The items and the status are private: a name starting with # is a JavaScript private element which, as MDN puts it, cannot be legally referenced outside the class body, and the language itself enforces that privacy.

order.ts
export type OrderStatus = "new" | "paid" | "cancelled";
export type LineItem = { name: string; price: number; qty: number };

export class Order {
  readonly id: string;
  #items: LineItem[] = [];
  #status: OrderStatus = "new";

  constructor(id: string) {
    this.id = id;
  }

  addItem(name: string, price: number, qty = 1): void {
    this.#assertEditable();
    if (!Number.isInteger(qty) || qty <= 0) {
      throw new RangeError("Quantity must be a whole number above zero");
    }
    if (price < 0) throw new RangeError("Price cannot be negative");
    this.#items.push({ name, price, qty });
  }

  pay(): void {
    this.#assertEditable();
    if (this.#items.length === 0) throw new Error("An empty order cannot be paid");
    this.#status = "paid";
  }

  // Getters: readable from outside, but not assignable
  get status(): OrderStatus {
    return this.#status;
  }

  get total(): number {
    return this.#items.reduce((sum, item) => sum + item.price * item.qty, 0);
  }

  // Return copies so the contents cannot be changed bypassing addItem
  get items(): LineItem[] {
    return this.#items.map((item) => ({ ...item }));
  }

  #assertEditable(): void {
    if (this.#status !== "new") {
      throw new Error(`Order ${this.id} is ${this.#status} and can no longer be changed`);
    }
  }
}

readonly id

What it does in the example
A public field that TypeScript will not let you reassign

#items, #status

What it does in the example
Private fields with initial values; unreachable from outside

constructor

What it does in the example
Runs on new Order(...) and fills in the fields

addItem, pay

What it does in the example
Public methods — the only way to change the order

get status, get total

What it does in the example
Getters: read like properties, but cannot be assigned

#assertEditable

What it does in the example
A private method with the shared check for every change

Encapsulation: invariants and getters

An invariant is a condition that must always hold for an object: an item's quantity is above zero, a paid order does not change, an empty order cannot be paid. The class checks invariants in every method that changes state and throws an error if a call would break them. That way an invalid order simply cannot exist.

order-demo.ts
import { Order } from "./order.ts";

const order = new Order("A-1001");
order.addItem("Latte", 1800, 2);
order.addItem("Cheesecake", 2400);
console.log(order.total); // 6000
console.log(order.status); // new

// Invariant: quantity is a positive whole number
try {
  order.addItem("Cappuccino", 1700, 0);
} catch (error) {
  console.log((error as Error).message); // Quantity must be a whole number above zero
}

// A copy of the contents does not affect the order itself
order.items[0].qty = 100;
console.log(order.total); // 6000

order.pay();
console.log(order.status); // paid

// After payment the contents cannot change
try {
  order.addItem("Croissant", 1500);
} catch (error) {
  console.log((error as Error).message); // Order A-1001 is paid and can no longer be changed
}

// Private fields are not reachable from outside, even at runtime
console.log(Object.keys(order)); // [ 'id' ]
console.log(JSON.stringify(order)); // {"id":"A-1001"}
Run: node order-demo.ts. The comments on the right are the actual output.

Note the items getter: it returns copies of the lines. If it returned the #items array itself, any outside code could call order.items.push(...) and bypass every check. Assigning to a getter-only property, such as order.status = "paid", is rejected by tsc, and at runtime inside a module it throws a TypeError.

TypeScript also has its own private keyword. The TypeScript handbook spells out the difference: private and protected are enforced only during type checking, and in JavaScript such a field remains an ordinary property — visible through bracket notation, Object.keys and JSON.stringify. Fields with # stay private after compilation.

private-vs-hash.ts
// TypeScript's private is checked at compile time only
class SoftPrivate {
  private token = "YOUR_API_KEY";
}

// JavaScript's # privacy is enforced by the language itself at runtime
class HardPrivate {
  #token = "YOUR_API_KEY";

  hasToken(): boolean {
    return this.#token.length > 0;
  }
}

const soft = new SoftPrivate();
console.log(soft["token"]); // YOUR_API_KEY — TypeScript allows bracket-notation access
console.log(JSON.stringify(soft)); // {"token":"YOUR_API_KEY"}

const hard = new HardPrivate();
console.log(JSON.stringify(hard)); // {}
console.log(Object.keys(hard)); // []
console.log(hard.hasToken()); // true
Run: node private-vs-hash.ts.

Inheritance and super

Inheritance lets you describe shared behavior once in a parent class and give it to children through extends. Take payment methods: a card and installments both charge a percentage fee and differ only in the percentage and the name. We move the shared logic into an abstract class — it cannot be created with new and exists only as a parent.

payments.ts
// The contract: anything that can name itself and compute a fee is a payment method
export interface PaymentMethod {
  readonly title: string;
  fee(amount: number): number;
}

// Shared percentage-fee logic lives in the base class
abstract class PercentFeePayment implements PaymentMethod {
  abstract readonly title: string;
  readonly #percent: number;

  constructor(percent: number) {
    if (percent < 0) throw new RangeError("A fee cannot be negative");
    this.#percent = percent;
  }

  fee(amount: number): number {
    return Math.round((amount * this.#percent) / 100);
  }
}

export class CardPayment extends PercentFeePayment {
  readonly title = "Bank card";

  constructor() {
    super(2); // call the parent constructor before touching this
  }
}

export class InstallmentPayment extends PercentFeePayment {
  readonly title: string;

  constructor(months: number) {
    super(months); // for illustration: 1% per month
    this.title = `Installments, ${months} months`;
  }

  // Override the method and extend the parent's result
  override fee(amount: number): number {
    return super.fee(amount) + 500; // plus a processing charge
  }
}

// Inherits nothing: fulfilling the interface contract is enough
export class CashOnDelivery implements PaymentMethod {
  readonly title = "Cash to courier";

  fee(): number {
    return 300;
  }
}
The fees in this example are made up for illustration.
  1. extends

    CardPayment extends PercentFeePayment — the child gets the fee method and the percentage check from the parent.

  2. super(...)

    A child constructor calls the parent constructor first. TypeScript and JavaScript require this before the first use of this.

  3. override

    InstallmentPayment overrides fee and uses super.fee(amount) to take the parent's result and add to it. With noImplicitOverride, the override keyword is mandatory, so an accidental override does not slip through.

  4. abstract

    The abstract title field forces every child to set a name, otherwise tsc reports an error.

A parent's private fields are not available to children: MDN notes that # elements are not part of prototypal inheritance and are visible only in the body of the class that declares them. #percent belongs to PercentFeePayment, and children reach it only through fee. If a field must be visible to children, TypeScript offers protected, but again that is a compile-time check only.

Polymorphism with payment methods

Polymorphism is the ability to work with different objects through a shared contract. The checkout function does not care whether it gets a card, installments or cash: it only needs the object to have title and fee. The contract is the PaymentMethod interface.

checkout.ts
import { CardPayment, CashOnDelivery, InstallmentPayment } from "./payments.ts";
import type { PaymentMethod } from "./payments.ts";

// The function knows only the PaymentMethod contract, not the concrete classes
function checkout(total: number, method: PaymentMethod): string {
  const fee = method.fee(total);
  return `${method.title}: ${total} + ${fee} = ${total + fee} ₸`;
}

const card = new CardPayment();
const installment = new InstallmentPayment(6);
const cash = new CashOnDelivery();

console.log(checkout(20000, card)); // Bank card: 20000 + 400 = 20400 ₸
console.log(checkout(20000, installment)); // Installments, 6 months: 20000 + 1700 = 21700 ₸
console.log(checkout(20000, cash)); // Cash to courier: 20000 + 300 = 20300 ₸

console.log(installment instanceof CardPayment); // false
console.log(Object.getPrototypeOf(InstallmentPayment) === Object.getPrototypeOf(CardPayment)); // true — a shared parent
Run: node checkout.ts.

CashOnDelivery inherits nothing, yet it fits checkout just as well as the classes with a shared parent. Type compatibility in TypeScript is structural: the handbook explains that types are related by their members, not by declared origin. Hence the practical rule: describe the contract with an interface, and keep inheritance for cases where you genuinely need a shared implementation.

Composition over inheritance

Inheritance describes an "is a" relationship: installments are a payment method with a percentage fee. Composition describes a "has a" relationship: pricing has a set of rules. Inheritance gets into trouble when features combine. A discount, express delivery, a promo code — if each combination gets its own order subclass, you end up with DiscountedOrder, ExpressOrder, DiscountedExpressOrder and so on, and the number of classes grows with every new rule.

pricing.ts
// A price rule is a small object with a single responsibility
export interface PriceRule {
  readonly label: string;
  apply(amount: number): number;
}

export class PercentDiscount implements PriceRule {
  readonly label: string;
  readonly #percent: number;

  constructor(percent: number) {
    if (percent < 0 || percent > 100) throw new RangeError("A discount is between 0 and 100%");
    this.#percent = percent;
    this.label = `${percent}% off`;
  }

  apply(amount: number): number {
    return Math.round(amount * (1 - this.#percent / 100));
  }
}

export class ExpressDelivery implements PriceRule {
  readonly label = "Express delivery";

  apply(amount: number): number {
    return amount + 1500;
  }
}

// Pricing HAS rules rather than inheriting them
export class Pricing {
  readonly #rules: PriceRule[];

  constructor(rules: PriceRule[]) {
    this.#rules = [...rules];
  }

  total(subtotal: number): number {
    return this.#rules.reduce((amount, rule) => rule.apply(amount), subtotal);
  }

  describe(): string {
    return this.#rules.map((rule) => rule.label).join(" → ") || "no rules";
  }
}
pricing-demo.ts
import { ExpressDelivery, PercentDiscount, Pricing } from "./pricing.ts";
import type { PriceRule } from "./pricing.ts";

// Any combination is assembled without new classes
const regular = new Pricing([]);
const promo = new Pricing([new PercentDiscount(10)]);
const promoExpress = new Pricing([new PercentDiscount(10), new ExpressDelivery()]);

console.log(regular.total(20000), regular.describe()); // 20000 no rules
console.log(promo.total(20000), promo.describe()); // 18000 10% off
console.log(promoExpress.total(20000), promoExpress.describe()); // 19500 10% off → Express delivery

// A plain object works too: TypeScript checks the shape, not the lineage
const roundDown: PriceRule = { label: "Round down to 100", apply: (amount) => Math.floor(amount / 100) * 100 };
console.log(new Pricing([new PercentDiscount(7), roundDown]).total(20250)); // 18800
Run: node pricing-demo.ts. A new combination of rules is a new line, not a new class.

Inheritance fits when

  • The "is a" relationship always holds, not just in some cases
  • The hierarchy is small and stable: one or two levels
  • Children need the parent's shared implementation and invariants
  • A framework requires it, for example a base exception class

Composition fits when

  • Features combine: discount, delivery, promo code in any mix
  • Behavior must change while the program runs
  • The parts need to be tested separately
  • Inheritance would force you to override methods just to switch something off

"Prefer composition over inheritance" is a widely accepted engineering guideline, not a language rule. In our experience, deep hierarchies in application code almost always turn into a chain of overrides that nobody dares to touch. One or two levels of inheritance for shared implementation are fine; beyond that, assemble behavior from objects. More on design principles in the materials on SOLID and design patterns.

What it looks like in Java

The same ideas carry over to Java almost one to one, and learning them in TypeScript makes the move to the backend easier. Below are the order and two payment methods in a single file. Differences from TypeScript: Java's private modifier is enforced by both the compiler and the virtual machine; a record creates an immutable data class; and, as the dev.java documentation states, every class has exactly one direct superclass — there is no multiple inheritance of classes, though a class can implement several interfaces.

Shop.java
import java.util.ArrayList;
import java.util.List;

record LineItem(String name, int price, int qty) {}

enum OrderStatus { NEW, PAID }

class Order {
    private final String id;
    private final List<LineItem> items = new ArrayList<>();
    private OrderStatus status = OrderStatus.NEW;

    Order(String id) {
        this.id = id;
    }

    void addItem(String name, int price, int qty) {
        assertEditable();
        if (qty <= 0) throw new IllegalArgumentException("Quantity must be a whole number above zero");
        if (price < 0) throw new IllegalArgumentException("Price cannot be negative");
        items.add(new LineItem(name, price, qty));
    }

    void pay() {
        assertEditable();
        if (items.isEmpty()) throw new IllegalStateException("An empty order cannot be paid");
        status = OrderStatus.PAID;
    }

    OrderStatus getStatus() { return status; }

    int getTotal() {
        return items.stream().mapToInt(item -> item.price() * item.qty()).sum();
    }

    List<LineItem> getItems() { return List.copyOf(items); } // an unmodifiable copy

    private void assertEditable() {
        if (status != OrderStatus.NEW) {
            throw new IllegalStateException("Order " + id + " is " + status + " and can no longer be changed");
        }
    }
}

interface PaymentMethod {
    String title();
    int fee(int amount);
}

class CardPayment implements PaymentMethod {
    public String title() { return "Bank card"; }
    public int fee(int amount) { return Math.round(amount * 0.02f); }
}

class CashOnDelivery implements PaymentMethod {
    public String title() { return "Cash to courier"; }
    public int fee(int amount) { return 300; }
}

public class Shop {
    static String checkout(int total, PaymentMethod method) {
        int fee = method.fee(total);
        return method.title() + ": " + total + " + " + fee + " = " + (total + fee) + " ₸";
    }

    public static void main(String[] args) {
        Order order = new Order("A-1001");
        order.addItem("Latte", 1800, 2);
        order.addItem("Cheesecake", 2400, 1);
        System.out.println(order.getTotal()); // 6000

        order.pay();
        System.out.println(order.getStatus()); // PAID

        try {
            order.addItem("Croissant", 1500, 1);
        } catch (IllegalStateException error) {
            System.out.println(error.getMessage()); // Order A-1001 is PAID and can no longer be changed
        }

        System.out.println(checkout(20000, new CardPayment())); // Bank card: 20000 + 400 = 20400 ₸
        System.out.println(checkout(20000, new CashOnDelivery())); // Cash to courier: 20000 + 300 = 20300 ₸
    }
}
Checked in the eclipse-temurin:21 Docker image: javac -encoding UTF-8 -d out Shop.java && java -cp out Shop.

Private field

TypeScript
#items (or private — compile time only)
Java
private — also enforced at runtime

Read-only

TypeScript
readonly, a getter
Java
final, a getTotal() method

Contract

TypeScript
interface, compatibility by shape
Java
interface + explicit implements

Inheritance

TypeScript
extends, one parent class
Java
extends, exactly one direct superclass

Broken invariant

TypeScript
throw new RangeError(...)
Java
throw new IllegalArgumentException(...)
How this was verified5 · passedchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.