Пошук уроків, статей та іншого контенту
Порівняйте можливості класів та інтерфейсів і визначайте, коли використовувати реалізацію, композицію або контракт типів.
Клас і interface можуть описувати форму об’єкта, але виконують різні ролі:
клас описує дані та поведінку, а також існує під час виконання програми;
інтерфейс описує лише контракт типів і зникає після компіляції TypeScript у JavaScript.
class User {
constructor(
public readonly id: number,
public name: string
) {}
getDisplayName(): string {
return `${this.name} (#${this.id})`;
}
}
interface UserData {
id: number;
name: string;
}
const user = new User(1, "Олена");
console.log(user.getDisplayName());
// Олена (#1)User створює об’єкти, має конструктор і метод getDisplayName. На відміну від нього, UserData не створює жодного коду в JavaScript. Він лише перевіряє, що об’єкт має потрібні властивості.
Клас варто використовувати, якщо об’єкт:
має власний стан;
повинен містити поведінку;
має контролювати створення екземплярів;
повинен мати приватні або захищені поля;
бере участь у наслідуванні;
має бути доступним під час виконання через instanceof.
Клас поєднує дані та операції над ними. Це корисно, коли правила роботи з даними не можна залишати лише на відповідальність коду, який використовує об’єкт.
class BankAccount {
#balance = 0;
constructor(
public readonly accountNumber: string,
initialBalance = 0
) {
if (initialBalance < 0) {
throw new Error("Початковий баланс не може бути від’ємним");
}
this.#balance = initialBalance;
}
deposit(amount: number): void {
if (amount <= 0) {
throw new Error("Сума поповнення має бути додатною");
}
this.#balance += amount;
}
withdraw(amount: number): boolean {
if (amount <= 0 || amount > this.#balance) {
return false;
}
this.#balance -= amount;
return true;
}
get balance(): number {
return this.#balance;
}
}
const account = new BankAccount("UA123", 100);
account.deposit(50);
account.withdraw(30);
console.log(account.balance); // 120Поле #balance є приватним на рівні JavaScript. Код поза класом не може змінити його напряму:
// account.#balance = 100000; // Помилка компіляціїІнтерфейс варто використовувати, якщо потрібно:
описати форму об’єкта;
визначити контракт для функції або сервісу;
дозволити різним класам мати спільний набір операцій;
відокремити код, який використовує об’єкт, від конкретної реалізації;
описати об’єктні дані, що надходять із зовнішнього джерела.
Інтерфейс не нав’язує спосіб реалізації. Йому важливо лише, щоб об’єкт відповідав потрібній структурі.
interface PaymentProvider {
pay(amount: number): Promise<string>;
}
async function processPayment(
provider: PaymentProvider,
amount: number
): Promise<string> {
return provider.pay(amount);
}
const testProvider: PaymentProvider = {
async pay(amount) {
return `Тестовий платіж на суму ${amount} виконано`;
}
};
processPayment(testProvider, 500).then(console.log);processPayment не знає, хто саме виконує платіж. Це може бути тестовий об’єкт, клас для банківського API або інша реалізація.
implementsКлас може реалізовувати інтерфейс за допомогою implements.
interface Logger {
log(message: string): void;
error(message: string): void;
}
class ConsoleLogger implements Logger {
log(message: string): void {
console.log(`[INFO] ${message}`);
}
error(message: string): void {
console.error(`[ERROR] ${message}`);
}
}implements перевіряє, що клас має потрібні властивості та методи. Однак воно не змінює поведінку класу і не створює реалізацію автоматично.
interface Identifiable {
id: string;
}
class Product implements Identifiable {
constructor(
public id: string,
public name: string
) {}
}Якщо клас не реалізує контракт повністю, TypeScript повідомить про помилку:
interface Printable {
print(): void;
}
class Document implements Printable {
// Помилка: метод print відсутній
}implements не означає наслідуванняІнтерфейс не передає класу код методів:
interface CanRun {
run(): void;
}
class Robot implements CanRun {
run(): void {
console.log("Робот рухається");
}
}CanRun лише вимагає наявності run. Як саме цей метод працює, визначає Robot.
Також implements не змінює тип властивостей класу і не дозволяє використовувати властивості інтерфейсу всередині класу без їхнього оголошення.
Назва класу може використовуватися як тип екземпляра:
class Notification {
constructor(public message: string) {}
}
function sendNotification(notification: Notification): void {
console.log(notification.message);
}
sendNotification(new Notification("Готово"));Але це не означає, що функція прийме будь-який об’єкт із таким самим полем:
sendNotification({
message: "Готово"
});Для структурної типізації TypeScript такий об’єкт зазвичай сумісний із класом, якщо клас не має приватних або protected членів. Якщо важливо приймати саме екземпляри певного класу, використовуйте перевірку під час виконання:
class UserSession {
constructor(public userId: number) {}
}
function isUserSession(value: unknown): value is UserSession {
return value instanceof UserSession;
}
const session = new UserSession(42);
console.log(isUserSession(session)); // true
console.log(isUserSession({ userId: 42 })); // falseІнтерфейс у такій перевірці використати не можна:
interface SessionData {
userId: number;
}
// Неможливо:
// value instanceof SessionDataПісля компіляції SessionData не існує в JavaScript.
Класове наслідування можна використовувати, коли між сутностями є справжній зв’язок «є різновидом»:
class Employee {
constructor(public name: string) {}
describe(): string {
return `Працівник: ${this.name}`;
}
}
class Developer extends Employee {
writeCode(): string {
return `${this.name} пише код`;
}
}
const developer = new Developer("Андрій");
console.log(developer.describe());
console.log(developer.writeCode());Developer є різновидом Employee, тому наслідування тут може бути доречним.
Проте не кожну спільну можливість потрібно реалізовувати через extends. Якщо об’єкт використовує інший об’єкт, це часто є композицією:
interface Formatter {
format(value: number): string;
}
class CurrencyFormatter implements Formatter {
format(value: number): string {
return `${value.toFixed(2)} грн`;
}
}
class Invoice {
constructor(
private readonly formatter: Formatter
) {}
getTotalText(total: number): string {
return this.formatter.format(total);
}
}
const invoice = new Invoice(new CurrencyFormatter());
console.log(invoice.getTotalText(1250));
// 1250.00 грнInvoice не є різновидом CurrencyFormatter. Він використовує форматер. Це композиція: клас отримує залежність і делегує їй частину роботи.
Порівняння:
extends — об’єкт є спеціалізованою версією іншого об’єкта;
композиція — об’єкт містить або використовує інші об’єкти;
implements — клас відповідає певному контракту.
Композиція часто зручніша, коли поведінку потрібно замінювати або комбінувати:
class PlainTextFormatter implements Formatter {
format(value: number): string {
return String(value);
}
}
const plainInvoice = new Invoice(new PlainTextFormatter());
console.log(plainInvoice.getTotalText(1250));
// 1250Invoice не змінився — змінився лише об’єкт, переданий у конструктор.
Інтерфейс особливо корисний, коли один код має працювати з кількома реалізаціями.
interface Storage {
save(key: string, value: string): void;
load(key: string): string | undefined;
}
class MemoryStorage implements Storage {
private readonly data = new Map<string, string>();
save(key: string, value: string): void {
this.data.set(key, value);
}
load(key: string): string | undefined {
return this.data.get(key);
}
}
class UserPreferences {
constructor(private readonly storage: Storage) {}
setTheme(theme: string): void {
this.storage.save("theme", theme);
}
getTheme(): string {
return this.storage.load("theme") ?? "light";
}
}
const preferences = new UserPreferences(new MemoryStorage());
preferences.setTheme("dark");
console.log(preferences.getTheme()); // darkUserPreferences залежить не від конкретного класу MemoryStorage, а від контракту Storage. Тому реалізацію можна замінити без змін у UserPreferences.
Наприклад, для тесту достатньо передати простий об’єкт:
const fakeStorage: Storage = {
save(key, value) {
console.log(`Збережено ${key}=${value}`);
},
load() {
return "light";
}
};
const testPreferences = new UserPreferences(fakeStorage);
console.log(testPreferences.getTheme());
// lightЦе одна з головних переваг контрактів: код залежить від потрібних можливостей, а не від деталей конкретної реалізації.
Інтерфейс часто підходить для опису даних, які передаються між функціями:
interface CreateUserInput {
name: string;
email: string;
}
function createUser(input: CreateUserInput): string {
return `${input.name} <${input.email}>`;
}
console.log(
createUser({
name: "Марія",
email: "maria@example.com"
})
);Клас не обов’язково потрібен, якщо об’єкт є простим набором даних і не має власних правил поведінки.
Клас доречніший, якщо потрібно централізувати операції та інваріанти:
interface RectangleData {
width: number;
height: number;
}
class Rectangle {
constructor(
public readonly width: number,
public readonly height: number
) {
if (width <= 0 || height <= 0) {
throw new Error("Сторони мають бути додатними");
}
}
get area(): number {
return this.width * this.height;
}
}У цьому прикладі клас не дозволяє створити прямокутник із недійсними розмірами.
TypeScript використовує структурну типізацію. Об’єкт сумісний з інтерфейсом, якщо має необхідні властивості потрібних типів:
interface HasName {
name: string;
}
const user = {
name: "Софія",
age: 25
};
function greet(value: HasName): string {
return `Привіт, ${value.name}!`;
}
console.log(greet(user));Об’єкт має додаткове поле age, але це не заважає передати його туди, де потрібне лише name.
Тому інтерфейс не є вимогою створювати об’єкти певним конструктором. Він визначає, що об’єкт уміє або які дані має, а не звідки він походить.
Поставте собі такі запитання:
Чи потрібні конструктор, методи або контроль внутрішнього стану?
Так — розгляньте клас.
Чи потрібно лише описати форму даних?
Так — використайте інтерфейс.
Чи має код працювати з кількома взаємозамінними реалізаціями?
Опишіть інтерфейс і залежіть від нього.
Чи є об’єкт спеціалізованим різновидом іншого?
Можливе наслідування через extends.
Чи об’єкт просто використовує іншу відповідальність?
Віддайте перевагу композиції.
Чи потрібна перевірка типу під час виконання?
Використовуйте клас або власний type guard, оскільки інтерфейс після компіляції недоступний.
Типова структура може виглядати так:
interface Notifier {
notify(message: string): void;
}
class EmailNotifier implements Notifier {
notify(message: string): void {
console.log(`Надсилання email: ${message}`);
}
}
class ReportService {
constructor(private readonly notifier: Notifier) {}
createReport(): void {
// Тут може бути підготовка звіту
this.notifier.notify("Звіт створено");
}
}
const service = new ReportService(new EmailNotifier());
service.createReport();У цьому прикладі:
Notifier — контракт;
EmailNotifier — конкретна реалізація;
ReportService — клас із власною відповідальністю;
передавання Notifier у конструктор — композиція.
Інтерфейс не можна створити через new:
interface User {
name: string;
}
// const user = new User(); // ПомилкаЯкщо потрібне створення екземплярів і поведінка, використовуйте клас.
implementsimplements не генерує методи:
interface Exporter {
export(): string;
}
class CsvExporter implements Exporter {
// Метод потрібно реалізувати власноруч
export(): string {
return "id,name";
}
}Не варто створювати довгі ієрархії лише через спільний код. Якщо класу потрібна змінна або взаємозамінна поведінка, краще передати залежність через композицію.
instanceof для інтерфейсуІнтерфейс існує лише під час перевірки типів і не доступний під час виконання:
interface Admin {
role: "admin";
}
// value instanceof Admin неможливоДля перевірки даних під час виконання потрібні клас або явна функція-перевірка.
Якщо клас змушений реалізувати багато непотрібних методів, контракт, імовірно, потрібно розділити:
interface Reader {
read(): string;
}
interface Writer {
write(value: string): void;
}Невеликі інтерфейси простіше реалізовувати й тестувати.
Клас існує під час виконання, створює екземпляри та може містити стан, методи й правила.
Інтерфейс описує контракт типів і зникає після компіляції.
implements перевіряє відповідність класу інтерфейсу, але не додає реалізацію.
Використовуйте інтерфейси для форм даних і взаємозамінних реалізацій.
Використовуйте класи для об’єктів зі станом, поведінкою та контрольованим створенням.
extends описує наслідування, а композиція — використання однієї відповідальності іншою.
Якщо залежність описана інтерфейсом, реалізацію можна замінити без змін у споживачі.
Інтерфейси не можна перевіряти через instanceof; для перевірок під час виконання потрібен клас або явний type guard.