Архітектура та Шаблони: Класи в TypeScript
Розділ 4: Класи та Об'єктно-Орієнтоване Програмування
JavaScript починався як прототипна мова. Класи (class) з'явилися лише в ES6 (2015) як синтаксичний цукор.
TypeScript перетворює цей "цукор" на повноцінний інструмент для побудови надійної архітектури, схожої на Java або C#.
Але є нюанс: ми все ще в JavaScript. І це накладає свої особливості.
1. Клас - це Тип + Значення
У TypeScript, коли ви оголошуєте клас, ви створюєте одночасно дві речі:
- Тип (опис форми екземпляра класу).
- Конструктор (функція, яка створює об'єкти в Runtime).
class User {
name: string
constructor(name: string) {
this.name = name
}
}
// 1. Використовуємо як Тип
const u: User = new User('Alice')
// 2. Використовуємо як Значення (Constructor)
const UserClass = User
const u2 = new UserClass('Bob')
2. Модифікатори Доступу (Access Modifiers)
JS (до недавнього часу) не мав концепції приватності. Все було публічним. TS додає три рівні доступу.
public (Default)
Доступно всім. Можна не писати, це поведінка за замовчуванням.
private
Доступно тільки всередині цього класу. Навіть спадкоємці не бачать.
class Base {
private secret = 'shhh'
}
const b = new Base()
// console.log(b.secret); // 🛑 Error TS
// АЛЕ! В Runtime (в JS) це поле все ще доступне!
// console.log((b as any).secret); // "shhh"
protected
Доступно всередині класу та його спадкоємців. Ззовні — ні.
class Animal {
protected move() {
console.log('Moving')
}
}
class Dog extends Animal {
bark() {
this.move() // ✅ OK
}
}
const dog = new Dog()
// dog.move(); // 🛑 Error: Only accessible within class 'Animal' and its subclasses.
#private (JS Private Fields)
Це нативний синтаксис JS (ES2020), який TS теж підтримує.
Відмінність: це жорстка приватність (Hard Privacy). Навіть (b as any).#secret не спрацює в Chrome.
class HardSecret {
#pin: number
constructor(pin: number) {
this.#pin = pin
}
}
private (TS), якщо вам потрібна "soft privacy" (під час розробки) і менше навантаження на Runtime.
Використовуйте #private (JS), якщо ви пишете бібліотеку і хочете гарантувати, що ніхто не залізе в ваші нутрощі.3. Readonly Properties
Поля можна зробити доступними тільки для читання. Вони можуть бути змінені тільки в конструкторі.
class Configuration {
readonly apiKey: string
readonly endpoint: string = 'https://api.example.com'
constructor(key: string) {
this.apiKey = key // ✅ OK
}
updateKey() {
// this.apiKey = "new"; // 🛑 Error
}
}
4. Parameter Properties (Синтаксичний цукор конструктора)
Це одна з найулюбленіших фіч розробників Angular та NestJS. Замість того, щоб оголошувати поле, а потім присвоювати його в конструкторі...
// Classic WAY
class User {
public name: string
private age: number
constructor(name: string, age: number) {
this.name = name
this.age = age
}
}
...ви можете зробити це в один рух:
// TS WAY
class User {
constructor(
public name: string,
private age: number,
) {} // Тіло пусте, але поля створені і присвоєні!
}
Це скорочує код (boilerplate) в рази.
5. Getters & Setters
Використовуйте їх для інкапсуляції логіки або валідації.
class Circle {
private _radius: number = 0
get radius(): number {
return this._radius
}
set radius(value: number) {
if (value < 0) {
throw new Error('Radius cannot be negative')
}
this._radius = value
}
// Обчислювана властивість (тільки get)
get area(): number {
return Math.PI * this._radius ** 2
}
}
6. Abstract Classes
Абстрактний клас — це шаблон. Ви не можете створити його екземпляр (new AbstractClass()).
Він змушує спадкоємців реалізувати певні методи for you.
Патерн "Template Method":
abstract class Logger {
// Реалізований метод (спільна логіка)
log(message: string) {
const timestamp = new Date().toISOString()
this.save(`${timestamp}: ${message}`)
}
// Абстрактний метод (контракт)
protected abstract save(formattedMessage: string): void
}
class FileLogger extends Logger {
protected save(msg: string) {
console.log(`Writing to file: ${msg}`)
}
}
class ConsoleLogger extends Logger {
protected save(msg: string) {
console.log(`Stdout: ${msg}`)
}
}
Це основа для побудови розширюваних фреймворків.
7. Interfaces vs Classes (implements)
Клас може "імплементувати" інтерфейс. Це означає, що клас підписується дотримуватися контракту.
interface Pingable {
ping(): void
}
class Sonar implements Pingable {
ping() {
console.log('ping!')
}
}
// Можна імплементувати декілька
class SuperSonar implements Pingable, Disposable {
ping() {}
dispose() {}
}
private поле через інтерфейс.8. Static Members
Статичні властивості належать класу, а не об'єкту.
class MathUtils {
static readonly PI = 3.14
static calculateCircumference(r: number) {
return 2 * this.PI * r
}
}
console.log(MathUtils.PI) // 3.14
// const m = new MathUtils();
// m.PI; // 🛑 Error: Property 'PI' does not exist on type 'MathUtils'.
Статичні методи часто використовуються для створення Factory Methods.
class User {
private constructor(public name: string) {}
static createAdmin(name: string) {
return new User(name + ' (Admin)')
}
}
9. Generic Classes
Ми вже торкалися цього. Класи можуть бути дженериками.
class Queue<T> {
private data: T[] = []
push(item: T) {
this.data.push(item)
}
pop(): T | undefined {
return this.data.shift()
}
}
const tasks = new Queue<string>()
tasks.push('Clean code')
10. this Parameters
В JS контекст this — це динамічний жах. Він залежить від того, як викликана функція.
TS дозволяє типізувати this явно, вказуючи його як перший фейковий параметр.
class Handler {
info: string = ''
onClick(this: void, e: Event) {
// this.info = "Click"; // 🛑 Error: 'this' is void.
console.log('Clicked')
}
}
Це гарантує, що метод не використовує this неправильно (наприклад, якщо його передадуть як колбек без bind).
11. Decorators (Експериментальні vs Stage 3)
Це величезна тема. Декоратори — це мета-програмування. Ви можете змінювати поведінку класів та методів "на льоту". У TS 5.0 додана підтримка стандартних декораторів ECMAScript.
function Logged(originalMethod: any, context: ClassMethodDecoratorContext) {
const methodName = String(context.name)
function replacementMethod(this: any, ...args: any[]) {
console.log(`LOG: Entering method '${methodName}'.`)
const result = originalMethod.call(this, ...args)
console.log(`LOG: Exiting method '${methodName}'.`)
return result
}
return replacementMethod
}
class Person {
@Logged
greet() {
console.log('Hello!')
}
}
new Person().greet()
// LOG: Entering method 'greet'.
// Hello!
// LOG: Exiting method 'greet'.
Це використовується всюди в Angular та NestJS.
12. Mixins (Композиція поведінки)
Що, якщо ми хочемо успадкувати логіку від двох класів? TypeScript (як і JS) не підтримує множинне спадкування. Але ми можемо використати Mixins.
Mixin — це функція: (Клас) => НовийКлас.
type GConstructor<T = {}> = new (...args: any[]) => T
function Jumpable<TBase extends GConstructor>(Base: TBase) {
return class extends Base {
jump() {
console.log('Jumping!')
}
}
}
function Flyable<TBase extends GConstructor>(Base: TBase) {
return class extends Base {
fly() {
console.log('Flying!')
}
}
}
class Sprite {
name = 'Mario'
}
// Компонуємо!
class SuperHero extends Flyable(Jumpable(Sprite)) {}
const hero = new SuperHero()
hero.jump()
hero.fly()
console.log(hero.name)
Це дуже потужно, але може заплутати типи, якщо зловживати.
13. Design Patterns в TypeScript
TypeScript ідеально підходить для реалізації класичних GoF патернів.
Singleton
Гарантує, що існує тільки один екземпляр класу.
class Database {
private static instance: Database
// Приватний конструктор!
private constructor() {}
static getInstance(): Database {
if (!Database.instance) {
Database.instance = new Database()
}
return Database.instance
}
}
Observer (Типізований)
Дивись розділ про Generics (Event Bus).
Factory
abstract class Car {
abstract drive(): void
}
class Tesla extends Car {
drive() {}
}
class BMW extends Car {
drive() {}
}
class CarFactory {
create(type: 'tesla' | 'bmw'): Car {
if (type === 'tesla') return new Tesla()
return new BMW()
}
}
14. Case Study: Mini-ORM (Active Record)
Давайте спробуємо реалізувати патерн Active Record.
// Базовий клас для всіх моделей
abstract class BaseModel<T> {
constructor(public data: T) {}
// Метод, який зберігає дані (імітація)
async save(): Promise<this> {
console.log(`Saving to DB:`, this.data)
return this
}
// Статичний метод пошуку (Generic!)
static find<T, M extends BaseModel<T>>(
this: { new (data: T): M }, // Конструктор "самого себе"
id: number,
): M {
// У реальності тут був би запит до БД
return new this({} as T)
}
}
interface UserProps {
id: number
name: string
}
class User extends BaseModel<UserProps> {
get name() {
return this.data.name
}
}
// main
const user = User.find(1) // user має тип User
user.save()
15. Homework: Реалізація Stream API
Створіть клас Stream<T>, який дозволяє обробляти масиви як потоки (ліниво, або просто ланцюжком).
Методи:
static of<T>(...items: T[]): Stream<T>map<U>(fn: (item: T) => U): Stream<U>filter(fn: (item: T) => boolean): Stream<T>collect(): T[](термінальна операція)
Приклад використання:
const result = Stream.of(1, 2, 3, 4, 5)
.filter((n) => n % 2 === 0)
.map((n) => n * 10)
.collect()
// [20, 40]
Підказка: Зберігайте дані всередині як масив. Кожен метод (map, filter) повинен повертати new Stream(...).
16. Шпаргалка (Cheatsheet)
| Фіча | Опис | Приклад |
|---|---|---|
public | Доступно всім | public name |
private | Тільки в класі | private secret |
protected | Клас + Нащадки | protected baseMethod |
readonly | Тільки read | readonly id |
static | Class-level | static PI |
abstract | Шаблон (без new) | abstract class Base |
implements | Контракт | class User implements Person |
constructor(public x) | Shorthand | Авто-створення полів |
get/set | Accessors | get name() { ... } |
17. Polymorphism: Abstract Class vs Interface
Часте питання на співбесідах.
Interface:
- Тільки контракт (публічна частина).
- Немає реалізації.
- Не існує в Runtime.
- Можна імплементувати декілька (
implements A, B).
Abstract Class:
- Контракт + Часткова реалізація.
- Може мати
private/protectedметоди. - Існує в Runtime (як звичайний клас).
- Можна успадкувати тільки один (
extends A).
Коли що брати?
- Якщо вам треба "Is-A" відношення (Dog is an Animal) ->
Abstract Class. - Якщо вам треба "Can-Do" відношення (Dog can Run, Car can Run) ->
Interface.
18. Dependency Injection (Manual)
DI — це просто передача залежностей через конструктор, а не створення їх всередині.
// ❌ Погано: Жорстка зав'язка
class UserService {
private db = new Database() // Ми не можемо це протестувати без реальної БД
}
// ✅ Добре: Dependency Injection
interface IDatabase {
query(sql: string): any[]
}
class UserService {
constructor(private db: IDatabase) {} // Ми залежимо від абстракції, а не реалізації
getUsers() {
return this.db.query('SELECT * FROM users')
}
}
// Тестування
class MockDB implements IDatabase {
query() {
return [{ id: 1, name: 'Test' }]
}
}
const service = new UserService(new MockDB()) // Легко тестувати!
19. Case Study: State Pattern (Order State Machine)
Давайте реалізуємо машину станів для замовлення. Це класика ООП.
Замість switch(state) ми будемо використовувати поліморфізм.
interface OrderState {
pay(): void
ship(): void
cancel(): void
}
class Order {
public state: OrderState
constructor() {
this.state = new PendingState(this) // Початковий стан
}
public setState(state: OrderState) {
this.state = state
}
pay() {
this.state.pay()
}
ship() {
this.state.ship()
}
cancel() {
this.state.cancel()
}
}
// 1. Pending
class PendingState implements OrderState {
constructor(private order: Order) {}
pay() {
console.log('Payment successful.')
this.order.setState(new PaidState(this.order))
}
ship() {
console.log('Cannot ship unpaid order.')
}
cancel() {
console.log('Order cancelled.')
this.order.setState(new CancelledState())
}
}
// 2. Paid
class PaidState implements OrderState {
constructor(private order: Order) {}
pay() {
console.log('Already paid.')
}
ship() {
console.log('Shipping...')
this.order.setState(new ShippedState())
}
cancel() {
console.log('Refunding and cancelling...')
this.order.setState(new CancelledState())
}
}
// 3. Shipped & Cancelled (Implementation omitted for brevity)
class ShippedState implements OrderState {
pay() {}
ship() {}
cancel() {}
}
class CancelledState implements OrderState {
pay() {}
ship() {}
cancel() {}
}
// Usage
const order = new Order()
order.ship() // "Cannot ship unpaid order."
order.pay() // "Payment successful."
order.ship() // "Shipping..."
Цей код набагато чистіший за if-else простирадла.
20. Case Study: Command Pattern (Undo/Redo)
Уявіть текстовий редактор.
// Command Interface
interface Command {
execute(): void
undo(): void
}
// Editor Receiver
class Editor {
text: string = ''
}
// Concrete Command
class TypeCommand implements Command {
constructor(
private editor: Editor,
private textToAdd: string,
) {}
execute() {
this.editor.text += this.textToAdd
}
undo() {
this.editor.text = this.editor.text.slice(0, -this.textToAdd.length)
}
}
// Invoker
class CommandManager {
private history: Command[] = []
execute(cmd: Command) {
cmd.execute()
this.history.push(cmd)
}
undo() {
const cmd = this.history.pop()
cmd?.undo()
}
}
// Usage
const editor = new Editor()
const manager = new CommandManager()
manager.execute(new TypeCommand(editor, 'Hello '))
manager.execute(new TypeCommand(editor, 'World!'))
console.log(editor.text) // "Hello World!"
manager.undo()
console.log(editor.text) // "Hello "
21. Anti-Pattern: Anemic Domain Model
"Анемічна модель" — це коли у вас є класи тільки з даними (DTO), а вся логіка лежить в "Сервісах". В ООП дані і поведінка мають бути разом.
// ❌ Anemic
class User {
id: number
balance: number
}
class UserService {
withdraw(user: User, amount: number) {
if (user.balance < amount) throw new Error()
user.balance -= amount
}
}
// ✅ Rich Model
class User {
private _balance: number = 0
constructor(initial: number) {
this._balance = initial
}
withdraw(amount: number) {
if (this._balance < amount) throw new Error('Not enough funds')
this._balance -= amount
}
}
Багата модель захищає свої інваріанти. Ви не можете "випадково" зробити баланс від'ємним, просто змінивши поле.
22. Bonus: Mixins with State
Ми вже бачили Mixins, але давайте додамо їм стан.
type GConstructor<T = {}> = new (...args: any[]) => T
function Activatable<TBase extends GConstructor>(Base: TBase) {
return class extends Base {
isActive = false
activate() {
this.isActive = true
console.log('Activated!')
}
deactivate() {
this.isActive = false
console.log('Deactivated!')
}
}
}
class User {
name = 'Bob'
}
const ActiveUser = Activatable(User)
const u = new ActiveUser()
u.activate()
console.log(u.isActive) // true
23. Fluent Interface Pattern
Ми часто бачимо це в бібліотеках (jQuery, knex).
Ключ до успіху: методи повертають this.
class RequestBuilder {
private url: string = ''
private method: string = 'GET'
private data: any = null
setUrl(url: string): this {
this.url = url
return this // Магія тут
}
setMethod(method: 'GET' | 'POST'): this {
this.method = method
return this
}
setData(data: any): this {
this.data = data
return this
}
send() {
console.log(`Sending ${this.method} to ${this.url}`, this.data)
}
}
// Ланцюжок викликів
new RequestBuilder().setUrl('/api/users').setMethod('POST').setData({ name: 'Alice' }).send()
Що робить this типом повернення? Це дозволяє спадкоємцям продовжувати ланцюжок!
class AdvancedBuilder extends RequestBuilder {
setTimeout(ms: number): this {
// ...
return this
}
}
// new AdvancedBuilder().setUrl(...).setTimeout(...) // ✅ Працює!
24. Decorator: Memoization
Давайте напишемо корисний декоратор, який кешує результат виконання методу.
function Memoize(originalMethod: any, context: ClassMethodDecoratorContext) {
const cache = new Map<string, any>()
return function (this: any, ...args: any[]) {
const key = JSON.stringify(args)
if (cache.has(key)) {
console.log('Returning from cache:', key)
return cache.get(key)
}
const result = originalMethod.apply(this, args)
cache.set(key, result)
return result
}
}
class MathService {
@Memoize
fibonacci(n: number): number {
if (n <= 1) return n
return this.fibonacci(n - 1) + this.fibonacci(n - 2)
}
}
const s = new MathService()
console.time('First Call')
console.log(s.fibonacci(35)) // Довго
console.timeEnd('First Call')
console.time('Second Call')
console.log(s.fibonacci(35)) // Миттєво!
console.timeEnd('Second Call')
25. Repository Pattern (Generic)
Паттерн Репозиторій ізолює доступ до даних.
interface IEntity {
id: number
}
abstract class BaseRepository<T extends IEntity> {
protected items: T[] = []
add(item: T): void {
this.items.push(item)
}
getById(id: number): T | undefined {
return this.items.find((i) => i.id === id)
}
delete(id: number): void {
this.items = this.items.filter((i) => i.id !== id)
}
}
// User Repo
class UserRepository extends BaseRepository<User> {
// Можна додати специфічні методи
findByEmail(email: string): User | undefined {
return this.items.find((u) => u.name === email) // (imitation)
}
}
26. Value Object Pattern
Value Object — це об'єкт, який визначається своїм значенням, а не ідентифікатором (на відміну від Entity). Він має бути імутабельним.
class Money {
constructor(
public readonly amount: number,
public readonly currency: string,
) {
if (amount < 0) throw new Error('Money cannot be negative')
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency
}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error('Currency mismatch')
}
return new Money(this.amount + other.amount, this.currency)
}
}
const price1 = new Money(100, 'USD')
const price2 = new Money(50, 'USD')
const total = price1.add(price2) // Новий об'єкт Money(150, "USD")
// price1.amount = 200; // 🛑 Error: Readonly
Це робить вашу бізнес-логіку надійною. Ви не можете "випадково" змінити ціну товару у кошику. Ви повинні створити нову ціну.
Резюме Розділу
Класи в TypeScript — це потужний інструмент для структурування складних систем. Вони додають суворість і передбачуваність, якої не вистачає чистому JS.
- Використовуйте модифікатори доступу для інкапсуляції.
- Абстрактні класи — для створення каркасів.
- Generics у класах — для універсальних структур (черги, стеки, дерева).
У наступному (і фінальному) розділі ми поговоримо про налаштування проекту, екосистему та просунуті конфігурації компілятора.