Объектно-ориентированное программирование (ООП) объединяет данные и поведение в объекты: заказ сам знает свой состав и правила, по которым его можно менять. Класс описывает такие объекты, приватные поля # прячут состояние, наследование позволяет переиспользовать общую логику через extends и super, а композиция собирает поведение из маленьких независимых объектов. На практике наследование подходит для небольших стабильных иерархий, а композиция — почти для всего остального.
После материала вы сможете
- описать класс с приватными полями #, геттерами и методами, которые защищают инварианты
- объяснить разницу между
privateв TypeScript и#в JavaScript - построить полиморфизм через интерфейс и наследование с
super - объяснить, когда предпочесть композицию наследованию, и собрать поведение из маленьких объектов
Что знать заранее
Проверено на
Node.js 24.18 LTSTypeScript 7.0Java 21 LTS
Сверено с документацией: 11 октября 2026 г.
Содержание
Объект как данные и поведение
В уроке о структурах данных заказ был простым объектом { id, status, total }, а правила работы с ним жили в отдельных функциях. Это нормально, пока правил мало. Но как только появляются условия «нельзя менять оплаченный заказ» или «количество — только целое положительное число», их приходится проверять везде, где заказ меняется, — и рано или поздно кто-то забудет.
ООП предлагает держать данные и правила вместе. Класс — шаблон, по которому создаются объекты; объект, созданный через new, называют экземпляром. Снаружи с экземпляром работают только через методы, а методы следят, чтобы данные не пришли в недопустимое состояние.
- Инкапсуляция — состояние скрыто, изменить его можно только через методы с проверками
- Наследование — класс-потомок получает поля и методы родителя и может их дополнить
- Полиморфизм — разные объекты выполняют один контракт, и код работает с любым из них
- Композиция — объект содержит другие объекты и делегирует им часть работы
Примеры написаны на TypeScript и запускаются в Node.js 24 напрямую. Начиная с Node.js 24.12 удаление типов (type stripping) стабильно: Node.js заменяет аннотации пробелами и выполняет код, но не проверяет типы. Поэтому проверку делаем отдельно — компилятором tsc с флагом --noEmit. Документация Node.js рекомендует для этого режима настройки erasableSyntaxOnly и verbatimModuleSyntax: они запрещают конструкции TypeScript, которые Node.js не умеет просто удалить, например enum и параметры-свойства в конструкторе.
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{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"strict": true,
"noEmit": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"noImplicitOverride": true,
"types": ["node"]
}
}Классы в TypeScript: constructor, методы, #private
Опишем заказ кафе. У него есть номер, который не меняется, состав и статус. Состав и статус приватные: имя с # — это приватный элемент JavaScript, к которому, как пишет MDN, нельзя законно обратиться вне тела класса, и эту приватность обеспечивает сам язык.
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("Количество — целое число больше нуля");
}
if (price < 0) throw new RangeError("Цена не может быть отрицательной");
this.#items.push({ name, price, qty });
}
pay(): void {
this.#assertEditable();
if (this.#items.length === 0) throw new Error("Нельзя оплатить пустой заказ");
this.#status = "paid";
}
// Геттеры: снаружи можно прочитать, но нельзя присвоить
get status(): OrderStatus {
return this.#status;
}
get total(): number {
return this.#items.reduce((sum, item) => sum + item.price * item.qty, 0);
}
// Отдаём копии, чтобы состав нельзя было изменить в обход addItem
get items(): LineItem[] {
return this.#items.map((item) => ({ ...item }));
}
#assertEditable(): void {
if (this.#status !== "new") {
throw new Error(`Заказ ${this.id} в статусе ${this.#status}, менять его нельзя`);
}
}
}readonly id
- Что делает в примере
- Публичное поле, TypeScript не даст присвоить его повторно
#items, #status
- Что делает в примере
- Приватные поля с начальными значениями; снаружи недоступны
constructor
- Что делает в примере
- Выполняется при
new Order(...)и заполняет поля
addItem, pay
- Что делает в примере
- Публичные методы — единственный способ изменить заказ
get status, get total
- Что делает в примере
- Геттеры: читаются как свойства, но присвоить им нельзя
#assertEditable
- Что делает в примере
- Приватный метод с общей проверкой для всех изменений
Инкапсуляция: инварианты и геттеры
Инвариант — условие, которое должно выполняться для объекта всегда: количество позиции больше нуля, оплаченный заказ не меняется, пустой заказ нельзя оплатить. Класс проверяет инварианты в каждом методе, который меняет состояние, и выбрасывает ошибку, если вызов их нарушает. Так некорректный заказ просто не может появиться.
import { Order } from "./order.ts";
const order = new Order("A-1001");
order.addItem("Латте", 1800, 2);
order.addItem("Чизкейк", 2400);
console.log(order.total); // 6000
console.log(order.status); // new
// Инвариант: количество — целое положительное число
try {
order.addItem("Капучино", 1700, 0);
} catch (error) {
console.log((error as Error).message); // Количество — целое число больше нуля
}
// Копия состава не влияет на сам заказ
order.items[0].qty = 100;
console.log(order.total); // 6000
order.pay();
console.log(order.status); // paid
// После оплаты состав менять нельзя
try {
order.addItem("Круассан", 1500);
} catch (error) {
console.log((error as Error).message); // Заказ A-1001 в статусе paid, менять его нельзя
}
// Приватные поля недоступны снаружи даже во время выполнения
console.log(Object.keys(order)); // [ 'id' ]
console.log(JSON.stringify(order)); // {"id":"A-1001"}node order-demo.ts. Комментарии справа — реальный вывод.Обратите внимание на геттер items: он возвращает копии позиций. Если бы он отдавал сам массив #items, любой код снаружи мог бы сделать order.items.push(...) и обойти все проверки. Попытка присвоить значение свойству только с геттером, например order.status = "paid", отклоняется уже на этапе tsc, а в модуле во время выполнения выбрасывает TypeError.
В TypeScript есть и своё ключевое слово private. Справочник TypeScript подчёркивает разницу: private и protected проверяются только при компиляции, а в JavaScript такое поле остаётся обычным свойством — его видно через квадратные скобки, Object.keys и JSON.stringify. Поля с # остаются приватными и после компиляции.
// private из TypeScript — проверка только при компиляции
class SoftPrivate {
private token = "YOUR_API_KEY";
}
// # из JavaScript — приватность обеспечивает сам язык во время выполнения
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 разрешает доступ через квадратные скобки
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()); // truenode private-vs-hash.ts.Наследование и super
Наследование позволяет описать общее поведение один раз в родительском классе, а потомкам — получить его через extends. Возьмём способы оплаты: у карты и рассрочки комиссия считается в процентах, отличаются только процент и название. Общую логику вынесем в абстрактный класс — его нельзя создать через new, он существует только как родитель.
// Контракт: всё, что умеет назвать себя и посчитать комиссию, — способ оплаты
export interface PaymentMethod {
readonly title: string;
fee(amount: number): number;
}
// Общая логика процентной комиссии — в базовом классе
abstract class PercentFeePayment implements PaymentMethod {
abstract readonly title: string;
readonly #percent: number;
constructor(percent: number) {
if (percent < 0) throw new RangeError("Комиссия не может быть отрицательной");
this.#percent = percent;
}
fee(amount: number): number {
return Math.round((amount * this.#percent) / 100);
}
}
export class CardPayment extends PercentFeePayment {
readonly title = "Банковская карта";
constructor() {
super(2); // вызвать конструктор родителя до обращения к this
}
}
export class InstallmentPayment extends PercentFeePayment {
readonly title: string;
constructor(months: number) {
super(months); // условно: 1% за каждый месяц
this.title = `Рассрочка на ${months} мес.`;
}
// Переопределяем метод и дополняем результат родителя
override fee(amount: number): number {
return super.fee(amount) + 500; // плюс плата за оформление
}
}
// Не наследует ничего: достаточно выполнить контракт интерфейса
export class CashOnDelivery implements PaymentMethod {
readonly title = "Наличные курьеру";
fee(): number {
return 300;
}
}extends
CardPayment extends PercentFeePayment— потомок получает методfeeи проверку процента из родителя.super(...)
В конструкторе потомка сначала вызывается конструктор родителя. TypeScript и JavaScript требуют сделать это до первого обращения к
this.override
InstallmentPaymentпереопределяетfee, а черезsuper.fee(amount)берёт результат родителя и дополняет его. С настройкойnoImplicitOverrideсловоoverrideобязательно — так случайное переопределение не пройдёт незамеченным.abstract
Абстрактное поле
titleобязывает каждого потомка задать название, иначеtscвыдаст ошибку.
Приватные поля родителя потомку недоступны: MDN отмечает, что #-элементы не участвуют в прототипном наследовании и видны только в теле того класса, где объявлены. #percent — дело PercentFeePayment, потомки работают с ним только через fee. Если поле должно быть видно потомкам, в TypeScript для этого есть protected, но это снова проверка только при компиляции.
Полиморфизм на примере способов оплаты
Полиморфизм — возможность работать с разными объектами через общий контракт. Функции оформления заказа неважно, карта это, рассрочка или наличные: ей достаточно, что у объекта есть title и fee. Контракт описан интерфейсом PaymentMethod.
import { CardPayment, CashOnDelivery, InstallmentPayment } from "./payments.ts";
import type { PaymentMethod } from "./payments.ts";
// Функция знает только контракт PaymentMethod, а не конкретные классы
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)); // Банковская карта: 20000 + 400 = 20400 ₸
console.log(checkout(20000, installment)); // Рассрочка на 6 мес.: 20000 + 1700 = 21700 ₸
console.log(checkout(20000, cash)); // Наличные курьеру: 20000 + 300 = 20300 ₸
console.log(installment instanceof CardPayment); // false
console.log(Object.getPrototypeOf(InstallmentPayment) === Object.getPrototypeOf(CardPayment)); // true — общий родительnode checkout.ts.CashOnDelivery ничего не наследует, но подходит функции checkout так же, как классы с общим родителем. В TypeScript совместимость типов структурная: справочник объясняет, что типы сравниваются по составу членов, а не по объявленному происхождению. Отсюда практическое правило — описывать контракт интерфейсом, а наследование оставлять для случаев, когда действительно нужна общая реализация.
Композиция вместо наследования
Наследование описывает отношение «является»: рассрочка является способом оплаты с процентной комиссией. Композиция описывает отношение «содержит»: расчёт цены содержит набор правил. Проблемы наследования начинаются, когда признаки комбинируются. Скидка, экспресс-доставка, промокод — если делать под каждое сочетание свой подкласс заказа, получится DiscountedOrder, ExpressOrder, DiscountedExpressOrder и так далее, а число классов будет расти с каждым новым правилом.
// Правило цены — маленький объект с одной обязанностью
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("Скидка — от 0 до 100%");
this.#percent = percent;
this.label = `Скидка ${percent}%`;
}
apply(amount: number): number {
return Math.round(amount * (1 - this.#percent / 100));
}
}
export class ExpressDelivery implements PriceRule {
readonly label = "Экспресс-доставка";
apply(amount: number): number {
return amount + 1500;
}
}
// Ценообразование СОДЕРЖИТ правила, а не наследует их
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(" → ") || "без правил";
}
}import { ExpressDelivery, PercentDiscount, Pricing } from "./pricing.ts";
import type { PriceRule } from "./pricing.ts";
// Любая комбинация собирается без новых классов
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 без правил
console.log(promo.total(20000), promo.describe()); // 18000 Скидка 10%
console.log(promoExpress.total(20000), promoExpress.describe()); // 19500 Скидка 10% → Экспресс-доставка
// Подойдёт и обычный объект: TypeScript сверяет форму, а не родословную
const roundDown: PriceRule = { label: "Округление до 100", apply: (amount) => Math.floor(amount / 100) * 100 };
console.log(new Pricing([new PercentDiscount(7), roundDown]).total(20250)); // 18800node pricing-demo.ts. Новое сочетание правил — новая строка, а не новый класс.Наследование подходит, когда
- Отношение «является» верно всегда, а не только в части случаев
- Иерархия маленькая и стабильная: один-два уровня
- Потомкам нужна общая реализация и общие инварианты родителя
- Так требует фреймворк: например, базовый класс исключения
Композиция подходит, когда
- Признаки комбинируются: скидка, доставка, промокод в любых сочетаниях
- Поведение нужно менять во время работы программы
- Части нужно тестировать по отдельности
- Наследование потребовало бы переопределять методы, чтобы что-то отключить
Принцип «предпочитайте композицию наследованию» — общепринятая инженерная рекомендация, а не правило языка. По нашему опыту, в прикладном коде глубокие иерархии почти всегда со временем превращаются в цепочку переопределений, которую страшно трогать. Одно-два наследования для общей реализации — нормально; дальше лучше собирать поведение из объектов. Подробнее о принципах проектирования — в материалах о SOLID и паттернах.
Как это выглядит в Java
Те же идеи переносятся в Java почти один к одному, и знакомство с ними на TypeScript упрощает переход на бэкенд. Ниже — заказ и два способа оплаты в одном файле. Отличия от TypeScript: модификатор private в Java проверяют и компилятор, и виртуальная машина; запись record создаёт неизменяемый класс данных; у каждого класса, как пишет документация dev.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("Количество — целое число больше нуля");
if (price < 0) throw new IllegalArgumentException("Цена не может быть отрицательной");
items.add(new LineItem(name, price, qty));
}
void pay() {
assertEditable();
if (items.isEmpty()) throw new IllegalStateException("Нельзя оплатить пустой заказ");
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); } // неизменяемая копия
private void assertEditable() {
if (status != OrderStatus.NEW) {
throw new IllegalStateException("Заказ " + id + " в статусе " + status + ", менять его нельзя");
}
}
}
interface PaymentMethod {
String title();
int fee(int amount);
}
class CardPayment implements PaymentMethod {
public String title() { return "Банковская карта"; }
public int fee(int amount) { return Math.round(amount * 0.02f); }
}
class CashOnDelivery implements PaymentMethod {
public String title() { return "Наличные курьеру"; }
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("Латте", 1800, 2);
order.addItem("Чизкейк", 2400, 1);
System.out.println(order.getTotal()); // 6000
order.pay();
System.out.println(order.getStatus()); // PAID
try {
order.addItem("Круассан", 1500, 1);
} catch (IllegalStateException error) {
System.out.println(error.getMessage()); // Заказ A-1001 в статусе PAID, менять его нельзя
}
System.out.println(checkout(20000, new CardPayment())); // Банковская карта: 20000 + 400 = 20400 ₸
System.out.println(checkout(20000, new CashOnDelivery())); // Наличные курьеру: 20000 + 300 = 20300 ₸
}
}javac -encoding UTF-8 -d out Shop.java && java -cp out Shop.Приватное поле
- TypeScript
#items(илиprivate— только при компиляции)- Java
private— проверяется и при выполнении
Только чтение
- TypeScript
readonly, геттер- Java
final, методgetTotal()
Контракт
- TypeScript
interface, совместимость по форме- Java
interface+ явноеimplements
Наследование
- TypeScript
extends, один родительский класс- Java
extends, ровно один прямой родитель
Нарушение инварианта
- TypeScript
throw new RangeError(...)- Java
throw new IllegalArgumentException(...)
Как проверено5 · пройденосверено 11 октября 2026 г.
- запущено, работает
order.ts, order-demo.ts, private-vs-hash.ts, payments.ts, checkout.ts, pricing.ts, pricing-demo.ts, tsconfig.json (RU и EN)
временный проект scratchpad/kb/verify/oop-osnovy (package.json type=module, typescript 7.0.2, @types/node 24.19.2): `npx tsc --noEmit` в ru/ и en/ — 0 ошибок; запуск `node <file>.ts` на Node 24.18.0 (type stripping) с --import check-preload.mjs — 22 из 22 комментариев с выводом совпали в каждой локали
- запущено, работает
решения упражнений (removeItem, BankTransfer, FixedDiscount), RU и EN
тот же tsc --noEmit + запуск: 10 из 10 комментариев совпали; removeItem в статье — фрагмент метода из проверенного файла ex-remove.ts
- запущено, работает
команды подготовки проекта (bash-блок)
выполнены по порядку в пустой папке с order.ts/order-demo.ts/tsconfig.json: установка прошла, tsc без ошибок, вывод order-demo совпал
- запущено, работает
негативные проверки (не в статье)
tsc ловит: доступ к order.#status снаружи (TS18013), private через точку (TS2341), присваивание геттеру (TS2540); node: SyntaxError для #status снаружи, TypeError при присваивании свойству только с геттером
- запущено, работает
Shop.java (RU и EN)
docker eclipse-temurin:21 (OpenJDK 21.0.12.1): javac -Xlint:all -encoding UTF-8 без предупреждений, java — 5 из 5 строк вывода совпали с комментариями (скрипт check-java.mjs)
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.