В серверной и клиентской разработке критически важно вовремя освобождать внешние системные ресурсы: закрывать соединения с базой данных, освобождать файловые дескрипторы операционной системы, прерывать сетевые сокеты, снимать мьютексы и останавливать таймеры.
Исторически единственным средством управления жизненным циклом ресурсов в 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] автоматически посылает в базу SQL-запрос ROLLBACK и возвращает физическое соединение в пул.
Захват распределенного мьютекса: await using lock = await redisMutex.acquire('order_42'). Исключается типовая уязвимость, когда сервис падает с необработанным исключением, а блокировка в Redis остается висеть до истечения TTL, замораживая обработку заказов.
При генерации PDF-отчетов или обработке изображений: using tempFile = createTempFile(). Файл гарантированно удаляется из файловой системы операционной системы при любом сценарии выхода из функции.
Когда количество ресурсов неизвестно заранее (например, открытие N файлов в цикле), используется DisposableStack: using stack = new DisposableStack().
Разберем продакшн-реализацию транзакционного контекста базы данных на базе AsyncDisposable, синхронного таймера на базе Disposable и контейнера AsyncDisposableStack.
Вызов фабрики ресурса. Переменная связывается со скрытым контекстом утилизации текущего лексического блока.
Бизнес-код выполняет запросы. В случае непредвиденного сбоя инициируется раскрутка стека (Stack Unwinding).
Блок кода завершается (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.