JavaScript починався як прототипна мова. Класи (class) з'явилися лише в ES6 (2015) як синтаксичний цукор.
TypeScript перетворює цей "цукор" на повноцінний інструмент для побудови надійної архітектури, схожої на Java або C#.
Але є нюанс: ми все ще в JavaScript. І це накладає свої особливості.
У TypeScript, коли ви оголошуєте клас, ви створюєте одночасно дві речі:
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')
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), якщо ви пишете бібліотеку і хочете гарантувати, що ніхто не залізе в ваші нутрощі.Поля можна зробити доступними тільки для читання. Вони можуть бути змінені тільки в конструкторі.
class Configuration {
readonly apiKey: string
readonly endpoint: string = 'https://api.example.com'
constructor(key: string) {
this.apiKey = key // ✅ OK
}
updateKey() {
// this.apiKey = "new"; // 🛑 Error
}
}
Це одна з найулюбленіших фіч розробників 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) в рази.
Використовуйте їх для інкапсуляції логіки або валідації.
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
}
}
Абстрактний клас — це шаблон. Ви не можете створити його екземпляр (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}`)
}
}
Це основа для побудови розширюваних фреймворків.
implements)Клас може "імплементувати" інтерфейс. Це означає, що клас підписується дотримуватися контракту.
interface Pingable {
ping(): void
}
class Sonar implements Pingable {
ping() {
console.log('ping!')
}
}
// Можна імплементувати декілька
class SuperSonar implements Pingable, Disposable {
ping() {}
dispose() {}
}
private поле через інтерфейс.Статичні властивості належать класу, а не об'єкту.
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)')
}
}
Ми вже торкалися цього. Класи можуть бути дженериками.
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')
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).
Це величезна тема. Декоратори — це мета-програмування. Ви можете змінювати поведінку класів та методів "на льоту". У 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.
Що, якщо ми хочемо успадкувати логіку від двох класів? 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)
Це дуже потужно, але може заплутати типи, якщо зловживати.
TypeScript ідеально підходить для реалізації класичних GoF патернів.
Гарантує, що існує тільки один екземпляр класу.
class Database {
private static instance: Database
// Приватний конструктор!
private constructor() {}
static getInstance(): Database {
if (!Database.instance) {
Database.instance = new Database()
}
return Database.instance
}
}
Дивись розділ про Generics (Event Bus).
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()
}
}
Давайте спробуємо реалізувати патерн 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()
Створіть клас 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(...).
| Фіча | Опис | Приклад |
|---|---|---|
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() { ... } |
Часте питання на співбесідах.
Interface:
implements A, B).Abstract Class:
private/protected методи.extends A).Коли що брати?
Abstract Class.Interface.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()) // Легко тестувати!
Давайте реалізуємо машину станів для замовлення. Це класика ООП.
Замість 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 простирадла.
Уявіть текстовий редактор.
// 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 "
"Анемічна модель" — це коли у вас є класи тільки з даними (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
}
}
Багата модель захищає свої інваріанти. Ви не можете "випадково" зробити баланс від'ємним, просто змінивши поле.
Ми вже бачили 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
Ми часто бачимо це в бібліотеках (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(...) // ✅ Працює!
Давайте напишемо корисний декоратор, який кешує результат виконання методу.
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')
Паттерн Репозиторій ізолює доступ до даних.
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)
}
}
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.
У наступному (і фінальному) розділі ми поговоримо про налаштування проекту, екосистему та просунуті конфігурації компілятора.