У серверній та клієнтській розробці критично важливо вчасно звільняти зовнішні системні ресурси: закривати з'єднання з базою даних, звільняти файлові дескриптори операційної системи, переривати мережеві сокети, знімати розподілені м'ютекси та зупиняти активні таймери.
Історично єдиним засобом управління життєвим циклом ресурсів у JavaScript залишався імперативний патерн try...finally:
// Legacy-патерн: розробник ЗОБОВ'ЯЗАНИЙ пам'ятати про ручне закриття ресурсу
const client = await pool.connect();
try {
await client.query('BEGIN');
await doWork(client);
await client.query('COMMIT');
} finally {
client.release(); // Якщо розробник забув finally — пул з'єднань блокується!
}Цей підхід вразливий до людського фактора:
try...finally перетворюється на нечитабельну «піраміду приреченості» (Pyramid of Doom).finally, він повністю маскує та перетирає первинну помилку з блоку try.Починаючи з TypeScript 5.2+ та стандарту ECMAScript, у мову увійшла специфікація Explicit Resource Management (Явне управління ресурсами) через нові оператори using та await using. Тепер деініціалізація прив'язана до лексичної області видимості та виконується середовищем виконання автоматично й детерміновано.
Оператори using та await using замінюють ненадійні імперативні блоки try...finally синтаксичними гарантіями мови: методи Symbol.dispose та Symbol.asyncDispose викликаються автоматично у строгому порядку LIFO при виході з лексичного блоку.
У парадигмі явного управління ресурсами будь-який об'єкт, що потребує очищення, реалізує стандартний інтерфейс:
[Symbol.dispose](): void.Promise<void>.Життєвий цикл ресурсу прив'язаний до лексичної області видимості (Scope Block) { ... }. Щойно потік виконання залишає межі блоку — через звичайне завершення коду, інструкцію return, break чи викидання помилки throw — середовище виконання гарантовано викликає методи фіналізації.
У разі оголошення кількох ресурсів очищення відбувається за правилом LIFO (Last In, First Out — останнім оголошено, першим закрито). Це гарантує, що дочірні сутності (наприклад, SQL-транзакція) закриються до того, як буде знищено фізичне з'єднання з базою даних.
+-------------------------------------------------------------------------+
| Архітектура Explicit Resource Management |
+-------------------------------------------------------------------------+
| Лексична область видимості (Scope Block { ... }): |
| { |
| using logFile = openFile('audit.log'); // Disposable |
| await using tx = await db.startTransaction(); // AsyncDisposable |
| |
| await tx.execute('INSERT INTO audit_logs...'); |
| // Вихід із блоку (return, завершення або викид throw) |
| } |
+-------------------------------------------------------------------------+
│
▼ Автоматичне розгортання стека (LIFO)
+-------------------------------------------------------------------------+
| Гарантований порядок деініціалізації: |
| 1. tx[Symbol.asyncDispose]() -> Відкат/коміт транзакції |
| 2. logFile[Symbol.dispose]() -> Закриття файлового дескриптора |
| • Ресурси звільняються строго у зворотному порядку оголошення |
| • При колізії помилок викидається стандартний SuppressedError |
+-------------------------------------------------------------------------+У разі виникнення помилок під час утилізації специфікація застосовує SuppressedError, зберігаючи як вихідний виняток програми, так і помилку закриття ресурсу.
Оператор using або await using реєструє об'єкт у прихованому стеку утилізації поточної лексичної області.
Виконуються робочі інструкції. У разі збою середовище негайно ініціює розгортання стека (Stack Unwinding).
Викликаються методи [Symbol.dispose] або [Symbol.asyncDispose] у строгому порядку, зворотному до оголошення.
Якщо помилка виникла і в тілі блоку, і в dispose(), створюється ланцюг SuppressedError без втрати стек-трейсів.
У блоці оголошено два ресурси: 'await using client = await getClient()' і наступним рядком 'await using tx = await client.startTransaction()'. В якому порядку середовище виконання викличе їхні методи [Symbol.asyncDispose]() при виході з функції?
Оголошення await using tx = await db.transaction(). Якщо транзакцію не було явно зафіксовано до виходу з області видимості (наприклад, сталася помилка валідації чи таймаут), метод [Symbol.asyncDispose] автоматично надсилає до бази запит ROLLBACK та повертає з'єднання до пулу.
Захоплення розподіленого м'ютекса: await using lock = await redisMutex.acquire('order_42'). Виключається типова проблема, коли сервіс падає з необробленою помилкою, а блокування в Redis зависає до кінця TTL, блокуючи систему.
Під час генерації PDF-звітів або обробки зображень: using tempFile = createTempFile(). Файл гарантовано видаляється з файлової системи за будь-якого сценарію виходу з функції.
Коли кількість ресурсів невідома заздалегідь (наприклад, відкриття N файлів у циклі), застосовується DisposableStack: using stack = new DisposableStack().
Розглянемо промислову реалізацію транзакційного контексту бази даних на базі AsyncDisposable та синхронного таймера на базі Disposable.
Виклик фабрики ресурсу. Змінна пов'язується з прихованим контекстом утилізації поточного блоку.
Бізнес-код виконує операції. У разі неочікуваної помилки середовище починає розгортання стека.
Блок коду завершується (return або throw). Рантайм викликає методи фіналізації у зворотному порядку.
Якщо помилка виникла і в тілі функції, і при відкаті tx, формується ланцюг SuppressedError.
// Ініціалізація системних символів у середовищах без нативної підтримки
if (!('dispose' in Symbol)) {
Object.defineProperty(Symbol, 'dispose', {
value: Symbol('Symbol.dispose'),
configurable: false,
enumerable: false,
writable: false,
});
}
if (!('asyncDispose' in Symbol)) {
Object.defineProperty(Symbol, 'asyncDispose', {
value: Symbol('Symbol.asyncDispose'),
configurable: false,
enumerable: false,
writable: false,
});
}export interface IDatabaseClient {
query: (sql: string, params?: readonly unknown[]) => Promise<unknown>;
release: () => void;
}
/**
* Клас транзакції з автоматичним відкатом при виході з області видимості
*/
export class SafeTransaction implements AsyncDisposable {
private isCommitted = false;
private isRolledBack = false;
constructor(
private readonly client: IDatabaseClient,
public readonly transactionId: string
) {}
public async query(sql: string, params?: readonly unknown[]): Promise<unknown> {
if (this.isCommitted || this.isRolledBack) {
throw new Error('Транзакція вже завершена');
}
return this.client.query(sql, params);
}
public async commit(): Promise<void> {
if (this.isCommitted || this.isRolledBack) {
throw new Error('Неможливо зафіксувати завершену транзакцію');
}
await this.client.query('COMMIT');
this.isCommitted = true;
}
/**
* Контракт AsyncDisposable: викликається автоматично при виході з блоку
*/
public async [Symbol.asyncDispose](): Promise<void> {
try {
if (!this.isCommitted && !this.isRolledBack) {
console.warn(`[SafeTransaction]: Автоматичний відкат незафіксованої транзакції ${this.transactionId}`);
await this.client.query('ROLLBACK');
this.isRolledBack = true;
}
} finally {
// Гарантовано повертаємо фізичне з'єднання назад до пулу
this.client.release();
}
}
}Що відбудеться, якщо в тілі функції виникне помилка 'Network Timeout', а всередині викликаного деструктора [Symbol.dispose]() станеться друга помилка 'Disk Write Error'?
Демонстрація банківської операції переказу коштів із синхронним таймером (using), асинхронною транзакцією БД (await using) та захистом від витоків з'єднань при виникненні бізнес-помилок.
import { SafeTransaction, IDatabaseClient } from '@/lib/database-transaction';
// Синхронний ресурс: вимірювач часу виконання операції
class ExecutionTimer implements Disposable {
private readonly start = performance.now();
constructor(private readonly operationName: string) {}
public [Symbol.dispose](): void {
const duration = (performance.now() - this.start).toFixed(2);
console.log(`[Metric]: Операцію "${this.operationName}" виконано за ${duration} мс`);
}
}
interface TransferParams {
fromAccountId: string;
toAccountId: string;
amount: number;
}
export async function executeTransfer(
dbPool: { connect: () => Promise<IDatabaseClient> },
params: TransferParams
): Promise<void> {
// 1. Синхронний ресурс (Disposable): вимірює загальний час роботи функції
using _timer = new ExecutionTimer('executeTransfer');
// 2. Асинхронний ресурс (AsyncDisposable): транзакція БД
const client = await dbPool.connect();
await using tx = new SafeTransaction(client, `tx_${Date.now()}`);
await tx.query('BEGIN');
// Імітація бізнес-валідації балансу
const senderBalance = 1000;
if (senderBalance < params.amount) {
// При викиді помилки tx[Symbol.asyncDispose]() виконає ROLLBACK і звільнить client,
// після чого _timer[Symbol.dispose]() зафіксує тривалість операції!
throw new Error('Недостатньо коштів на рахунку відправника');
}
// Списання та зарахування
await tx.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [params.amount, params.fromAccountId]);
await tx.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [params.amount, params.toAccountId]);
// Фіксація змін
await tx.commit();
// При штатному виході tx утилізується без відкату, а таймер запише тривалість
}Якщо об'єкт реалізує метод [Symbol.asyncDispose](), спроба оголосити його через синхронний оператор using obj = getAsyncResource() призведе до помилки компілятора: The resource must have a '[Symbol.dispose]()' method because it is used with 'using'. Синхронний using не очікує завершення асинхронних промісів.
// ❌ СИНТАКСИЧНА ПОМИЛКА: using для асинхронного ресурсу
using tx = new SafeTransaction(client, 'id_1'); // Помилка: SafeTransaction is an AsyncDisposable
// ✅ ПРАВИЛЬНО: Використання await using
await using tx = new SafeTransaction(client, 'id_1');Змінні, оголошені через using та await using, є неявно константними (const). Спроба змінити їхнє значення (tx = nextTx) викличе помилку компіляції: Cannot assign to 'tx' because it is a constant.
// ❌ ПОМИЛКА: Переприсвоєння using-змінної
using file = openFile('a.txt');
file = openFile('b.txt'); // Помилка TS2588: Cannot assign to 'file' because it is a constant.
// ✅ ПРАВИЛЬНО: Використання вкладених блоків або DisposableStackОголошення using res = openResource() на верхньому рівні файлу модуля без обгортання у функцію чи блок { ... } залишить ресурс відкритим до повного завершення процесу Node.js. Завжди ізолюйте короткоживучі ресурси у функціях або локальних блоках.
// ❌ АНТИПАТЕРН: Ресурс не звільниться до завершення всього процесу
using connection = createConnection();
// ✅ ПРАВИЛЬНО: Локалізація у функції чи лексичному блоці
function processBatch() {
using connection = createConnection();
// connection звільняється одразу після завершення processBatch()
}Специфікація Explicit Resource Management (using та await using) у TypeScript 5.2+ докорінно підвищує надійність додатків. Вона усуває багатоповерхові піраміди try...finally, захищає від витоків дескрипторів та завислих транзакцій у БД, забезпечує детермінований порядок деініціалізації LIFO та зберігає повний діагностичний контекст завдяки SuppressedError.