void: почему колбэк может вернуть что угодно

функции≈ 10 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

void в возврате читается как «здесь ничего нет», и половина правды в этом есть. Вторая половина неожиданнее: функцию, честно возвращающую число, компилятор спокойно кладёт туда, где обещан void, и не говорит ни слова. Из-за этого послабления работает forEach с push внутри — и из-за него же теряются возвращённые значения и целые промисы.

Разделы темы:·

Функция без return в JavaScript всё равно что-то отдаёт — undefined. В системе типов для этой ситуации есть отдельное имя: void. Писать его руками не обязательно, компилятор ставит его сам.

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

«Тип "void" не может быть назначен для типа "null"» — вот и ответ: возврат вывелся как void, хотя никакой аннотации мы не писали. А на исполнении в check всё-таки лежит undefined — разницу между этими двумя ответами разбирает следующий раздел.

Аннотацию : void ставят, когда хотят закрепить намерение: «эта функция работает ради побочного эффекта, смотреть на её возврат незачем». Нарушить обещание прямо в теле функции нельзя.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

TS2322, «Тип "number" не может быть назначен для типа "void"», на строке с return sum. Диагностика есть, но, как всегда, ничего не отменяет: код выполнился, шестёрка напечаталась, а сама функция вернула её вызывающему — типы до браузера не доезжают.

Способов честно вернуть void ровно три, и все три равноправны.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

«Функция ничего не возвращает» — неточность. Возвращает: undefined. void говорит другое — что на этот возврат не надо смотреть.

На исполнении разницы между ними нет никакой: и void-функция, и функция с return undefined отдают одно и то же значение. В типах разница есть, и она односторонняя.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Печатается undefined, а присвоить это же самое значение переменной типа undefined компилятор не даёт: TS2322, «Тип "void" не может быть назначен для типа "undefined"».

Причина в смысле, а не в значении. undefined — обычный тип с ровно одним значением, и это значение вы вправе разглядывать. void — пометка «возврат есть, но он не для вас»; сегодня там undefined, а завтра автор функции решит что-то возвращать, и ваш код не должен от этого зависеть. Поэтому void не пускают никуда, где ждут конкретное значение.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Две диагностики: void не лезет даже в string | undefined, где место под undefined явно есть, — а null в переменную типа void не лезет тем более. Обратное направление разрешено: undefined в void кладётся молча.

voidundefined
Что говорит про возвратвозврат игнорируют, его смысл не оговорёнфункция возвращает именно значение undefined
Принимает undefinedдада
Принимает null при strictнет, TS2322нет, TS2322
Результат кладётся в переменную типа undefinedнет, TS2322да
Можно проверить на истинностьнет, TS1345да, диагностики не будет

Последняя строка называет диагностику, которой в прошлых статьях не встречалось, — TS1345. Она ждёт вас в разделе про потерянный возврат, и там же станет понятно, почему запрет разумный.

В самом языке есть унарный оператор void: он вычисляет выражение и всегда отдаёт undefined. Записи void 0 и undefined дают одно и то же — приём остался со времён, когда undefined можно было переопределить как обычную переменную.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

К типу void оператор отношения не имеет: одно слово, две разные вещи, и живут они в разных мирах — оператор в значениях, тип в аннотациях.

Теперь главная странность. Соберём удвоенные числа привычным способом — через forEach и push.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни одной диагностики — и это удивительнее, чем кажется. push возвращает не undefined, а новую длину массива, то есть число. Стрелка без фигурных скобок этот результат отдаёт наружу. А в стандартной библиотеке forEach объявлен так: (value: T, index: number, array: T[]) => void. Число вместо void — и тишина.

Правило: если в типе слота стоит возврат void, туда разрешено класть функцию с любым возвратом. Значение просто выбросят. Работает это только тогда, когда тип приходит из контекста — из аннотации переменной, из сигнатуры параметра, из алиаса. Аннотация, написанная прямо на самой функции, остаётся обещанием, и его проверяют.

Четыре объявления ниже отличаются мелочами. Прежде чем запускать, отметьте, на каких строках будет диагностика.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Диагностики на строках 6 и 9. one возвращает строку в слот с void — можно, тип пришёл из Job. four возвращает ровно ту же строку — нельзя, потому что : void написано прямо на стрелке. У three ошибка встречная: функция без возврата попала в слот, который ждёт строку, — про это во врезке. Печатаются только two и three: one и four возвращают строку, но их результат никто не читает.

Зачем понадобилось послабление, видно из первого примера: без него doubled.push(n * 2) пришлось бы заворачивать в фигурные скобки только ради того, чтобы выбросить возврат. И так с каждым методом, который принимает колбэк «для побочного эффекта», — правило избавляет от этого налога всю стандартную библиотеку сразу.

Правило одностороннее. Функцию с возвратом можно положить в void-слот, но не наоборот: если слот ждёт значение, а функция его не даёт, это ошибка.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика приходит матрёшкой: сначала «Тип "() => void" не может быть назначен для типа "() => number"», затем вложенная причина — «Тип "void" не может быть назначен для типа "number"». Запуск подтверждает: в консоли undefined, а не число.

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

У послабления есть цена. «Возврат игнорируют» — это не «компилятор перестал следить»: значение действительно возвращается и на исполнении доступно, но тип выражения теперь void, и воспользоваться им не выйдет.

Самый частый случай — алиас для проверки, в котором забыли написать возврат.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

TS1345, «Выражение типа "void" не может быть проверено на истинность». Обиднее всего, что код совершенно правильный: запустите — и в консоли окажется ["рико", "код"], потому что стрелка и вправду возвращает boolean. Тип потерялся в момент присваивания в Check, и вернуть его на месте использования нечем.

Лечится это не приведением типа, а исправлением алиаса: type Check = (value: string) => boolean. Правило простое — void в типе колбэка означает «результат мне не нужен». Если он нужен, так и пишите.

Вторая половина той же беды — перепутать forEach с map. У forEach не только колбэк объявлен через void: он и сам возвращает void.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

«Тип "void" не может быть назначен для типа "number[]"» — аннотация сработала как ловушка для опечатки. Обратите внимание, что про сам колбэк (score) => score * 2 компилятор не сказал ничего: по правилу из прошлого раздела он имеет полное право возвращать число.

Без : number[] та же ошибка становится совершенно немой: тип переменной выведется как void, диагностики не будет, а в консоли окажется undefined.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

У асинхронной функции без возврата тип не void, а Promise<void>: значение всё-таки есть — промис, и его можно дождаться. Правило из третьего раздела этой разницы не замечает, потому что не различает, что именно кладут в void-слот.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Порядок вывода — весь урок целиком: сначала «все задачи запущены», и только потом «задача выполнена». Диагностик нет ни одной. Промис, который вернула асинхронная стрелка, лёг в слот () => void и был выброшен; runAll вернулся, не дождавшись ничего, а ошибка внутри такой задачи стала бы необработанным отказом промиса.

В стандартной библиотеке эта история встречается на каждом шагу.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Пустой массив, и снова ни слова от компилятора. forEach обошёл все три элемента, запустил три асинхронные функции, получил от каждой промис, выбросил его — и вернул управление раньше, чем отработал первый await. Заполнится prices позже, когда его уже никто не читает.

Починка не в том, чтобы уговорить forEach ждать, — он не умеет. Нужен метод, чей колбэк возвращает значение.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

У map в сигнатуре стоит => U, а не => void: возврат колбэка становится частью типа результата, и промисы никуда не деваются. Обычный for..of с await внутри годится не хуже — он ждёт по очереди, Promise.all запускает всё сразу. Такие места умеет находить и линтер: правило no-misused-promises ловит forEach с асинхронной стрелкой, подробнее о нём — в статье «typescript-eslint: правила, которых нет у компилятора».

Правило на каждый день: увидели async-стрелку в аргументе — проверьте по сигнатуре, ждёт ли её тот, кому вы её отдаёте.

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

TS2345, «Аргумент типа "() => void" нельзя назначить параметру типа "Task"», с вложенной причиной про Promise<void>. Строгость вернулась ровно потому, что в слоте больше не void.

  • void — не «пустота» и не undefined, а пометка «на возврат смотреть не надо»: значение есть, но его смысл не оговорён.
  • undefined в переменную типа void положить можно, результат void-функции в undefined — нельзя; проверять void на истинность тоже нельзя, это TS1345.
  • Если возврат void стоит в типе слота, туда принимают функцию с любым возвратом — именно поэтому forEach с push внутри не вызывает диагностики.
  • Послабление работает только для типа, пришедшего из контекста. Аннотация : void на самой функции — обещание, и return со значением его нарушает.
  • Возврат при этом действительно выбрасывают: на исполнении значение доступно, а в типах его больше нет — отсюда TS1345 и потерянные результаты проверок.
  • Promise<void> укладывается в void-слот так же молча, как число: асинхронный колбэк выполнится, но ждать его никто не будет.

1/4В forEach передали стрелку (n) => doubled.push(n * 2). Метод push возвращает число, а колбэк forEach объявлен с возвратом void. Что скажет компилятор?

2/4Две строки: const a: () => void = () => 42; и const b = (): void => 42;. Где будет диагностика?

3/4Объявлен type Check = (value: string) => void, в переменную этого типа положили (value) => value.trim() !== "", а дальше написали if (filled(word)). Что произойдёт?

4/4В массив Task[], где type Task = () => void, положили async () => { await ...; console.log("готово"); }, а затем вызвали все задачи в цикле. Что будет?

Проверка, которая ничего не сообщает

Фильтр отбирает непустые строки и работает правильно, но компилятор ругается на строке с if. Почините так, чтобы диагностика исчезла, а поведение осталось прежним. Тело стрелки трогать не нужно.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ошибка не в проверке и не в стрелке, а в алиасе: void объявляет, что возврат никому не нужен, — компилятор так и поступил. Раз результат нужен, его надо назвать.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

С честным типом функция становится обычным предикатом, и её можно отдать прямо в filter — цикл больше не нужен. Приведение вида as unknown as boolean здесь было бы худшим из решений: оно спрятало бы диагностику, оставив тип неверным.

Цены не собрались

Функция price возвращает цену асинхронно. Сбор цен написан через forEach — компилятор молчит, а в консоли пусто. Соберите все три цены и напечатайте их.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Колбэк forEach объявлен с возвратом void, поэтому промис асинхронной стрелки выброшен, и метод возвращается раньше первого await. Нужен метод, который возврат колбэка сохраняет.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вариант с for..of и await внутри тоже верен и читается проще, но ждёт цены по очереди; Promise.all запускает все запросы сразу. Обратите внимание, что тип возврата main — Promise<void>, а не void: значение есть, просто оно пустое.

Сколько будет диагностик

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика одна, на строке 10. Реализация метода претензий не вызывает: тип Logger пришёл из контекста, значит возврат number вместо void разрешён — это то же правило, что спасает forEach с push. А вот на месте вызова у выражения тип void, и в number он не укладывается: TS2322.

На исполнении при этом напечатается 6 — значение возвращается по-настоящему. Если длина сообщения действительно нужна вызывающему, править надо интерфейс:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Полезная привычка: увидев void в чужом интерфейсе, не рассчитывайте на возврат, даже если конкретная реализация что-то отдаёт. Автор типа явно сказал, что это не часть договора и завтра может измениться.