Зачем нужны using и await using

В JavaScript регулярно появляются новые возможности, и не про все из них мы вообще узнаём. Так, недавно я познакомилась с using и await using. Мне не довелось их применять на практике, но выглядит это интересно.

Смысл в том, чтобы привязать освобождение ресурса к границам блока. Обычно, если мы открыли файл или установили какое-то соединение, то обязаны не забыть всё это закрыть. Сейчас такой код пишется через try/finally, и выглядит примерно так:

const file = await open('data.txt');
try {
  const data = await file.read();
} finally {
  await file.close();
}

Здесь важно, что open стоит до try. Если убрать его внутрь блока, переменную придётся объявлять снаружи через let, а в finally добавлять ?., потому что при падении open значения там ещё нет. С await using всё это пропадает:

await using file = await open('data.txt');
const data = await file.read();

Файл закроется при любом выходе из блока: при нормальном завершении, исключении, return, break или continue. Разница здесь не столько в количестве строк, сколько в том, что исчезает целый класс возможностей ошибиться.

Работает это с любым объектом, у которого есть метод [Symbol.dispose]() для using или [Symbol.asyncDispose]() для await using. Стандарт описывает только этот протокол, всё остальное остаётся на рантаймах. При этом переменная, объявленная через using, ведёт себя как const, то есть переприсвоить её нельзя.

С поддержкой ситуация уже вполне боевая. Предложение вошло в стандарт ES2026. Node.js поддерживает синтаксис нативно с 24-й версии, TypeScript — с 5.2. В последних версиях браузеров тоже всё работает.

Но дальше начинаются нюансы, из-за которых этот синтаксис так редко встречается в реальном коде. Самая главная причина в том, что встроенных объектов, реализующих протокол, пока очень немного. В Node есть FileHandle из fs/promises, так что тот самый пример выше работает нативно. Однако несмотря на то, что постепенно появляются новые disposable-объекты, список всё ещё короткий.

В браузерных API встроенных disposable-объектов практически нет. Поэтому на клиенте using — это инструмент для своих объектов, а не для чужих, например:

function observe(el, cb) {
  const ro = new ResizeObserver(cb);
  ro.observe(el);
  return { [Symbol.dispose]: () => ro.disconnect() };
}

Второй нюанс касается сборки. TypeScript понимает синтаксис давно, но при target ниже ES2022 он требует, чтобы Symbol.dispose существовал в рантайме или чтобы использовался полифил.

И третье: в обычном продуктовом коде ресурсов, которые нужно явно освобождать, не так уж и много. На фронтенде большая часть работы — это данные и рендер, а там их чистит сборщик мусора. На бэкенде файлы и соединения обычно уже спрятаны внутрь библиотек, которые закрывают всё сами. Так что реальные кейсы для применения — это скрипты, утилиты и собственные абстракции над ресурсами.

Поэтому лично мой вывод: вещь полезная, но не универсальная. Её стоит держать в голове, но области применения пока ограничены.

Что происходит под капотом

Теперь посмотрим, во что этот синтаксис разворачивается.

Если очень грубо, то блок с объявлениями превращается в try/finally, где в finally вызываются собранные методы очистки. Но интересно не это, а детали, которые спрятаны в спецификации.

Самое неочевидное: метод очистки берётся у объекта в момент объявления, а не при выходе из блока. Рантайм сразу достаёт [Symbol.dispose], запоминает ссылку на функцию и кладёт её во внутренний стек области. Поэтому подмена метода после объявления ни на что не влияет.

const res = { [Symbol.dispose]() { console.log('первый'); } };
{
  using r = res;
  res[Symbol.dispose] = () => console.log('второй');
} // первый

Оттуда же следует и то, что если у объекта нет нужного метода, TypeError прилетит сразу на строке объявления. При этом null и undefined тоже разрешены, чтобы можно было писать необязательные ресурсы без проверок.

Теперь немного про await using. Слово await здесь относится к невидимому в коде вызову [Symbol.asyncDispose]() при выходе из области. Поэтому в строке await using file = await open(path) два await: второй ждёт открытия файла сейчас, первый — его закрытия потом.

Если у объекта есть только синхронный [Symbol.dispose](), await using возьмёт его. Однако обычный using на объекте с асинхронной очисткой бросит ошибку.

Самый тонкий момент — что будет, если исключение бросит и тело блока, и сама очистка. Обе ошибки сохраняются в обёртке нового типа — SuppressedError.

try {
  using r = { [Symbol.dispose]() { throw new Error('ошибка очистки'); } };
  throw new Error('ошибка тела');
} catch (e) {
  e.error;      // ошибка очистки
  e.suppressed; // ошибка тела
}

В error лежит та, что пришла последней и вытеснила предыдущую, а в suppressed — та, которую вытеснили. Если ресурсов несколько и падают они по очереди, получается цепочка вложенных SuppressedError, по которой можно дойти до самой первой.

И последнее, про что легко забыть. Ресурс освобождается при выходе из блока, а не тогда, когда на него перестанут ссылаться. Поэтому если вернуть из функции замыкание, захватившее такой объект, оно получит уже закрытый ресурс.

DisposableStack и AsyncDisposableStack

Итак, using привязывает освобождение ресурса к границам блока. Но у этого подхода есть предел: объявлений должно быть столько же, сколько ресурсов, и все они должны быть известны на момент написания кода. Для случаев, когда ресурсов заранее неизвестное количество, в стандарте есть DisposableStack и его асинхронный вариант AsyncDisposableStack.

Допустим, надо открыть файлы по списку путей и поработать со всеми сразу. Написать объявление в цикле не выйдет, потому что тело цикла — это отдельная область, и файл закроется на своей же итерации, а не в конце.

Стек решает это тем, что накапливает ресурсы по ходу дела, но сам при этом подчиняется тому же протоколу. То есть объявляется через using, и когда блок заканчивается, освобождает всё накопленное в обратном порядке.

await using stack = new AsyncDisposableStack();

const files = [];
for (const p of paths) {
  const file = stack.use(await open(p));
  files.push(file);
}
// работаем со всеми файлами

Метод use() кладёт объект в стек и возвращает его же, поэтому вызов удобно писать прямо вокруг выражения, которое ресурс создаёт, то есть так stack.use(await open(p)). В документации советуют не разносить получение ресурса и регистрацию на две строки, чтобы между ними не появился код, который может упасть. Ведь в этот момент стек ещё не знает про ресурс, а значит, не может его закрыть.

Также у стека есть метод adopt(). Он принимает значение и функцию для очистки, которая получит это значение аргументом:

stack.adopt(setInterval(tick, 1000), clearInterval);

Таким образом, стек оказывается универсальнее самого using. Для using объект обязан иметь [Symbol.dispose], а стеку достаточно передать функцию для закрытия. Поэтому стек может иметь смысл даже для единственного ресурса, если у того нет нужного протокола:

using stack = new DisposableStack();
stack.adopt(handler, h => emitter.off('data', h));

Заметка собрана из постов в Telegram-канале @startpoint_dev: первый, второй, третий.