void: почему колбэк может вернуть что угодно
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
void в возврате читается как «здесь ничего нет», и половина правды в этом есть. Вторая половина неожиданнее: функцию, честно возвращающую число, компилятор спокойно кладёт туда, где обещан void, и не говорит ни слова. Из-за этого послабления работает forEach с push внутри — и из-за него же теряются возвращённые значения и целые промисы.
Функция без return в JavaScript всё равно что-то отдаёт — undefined. В системе типов для этой ситуации есть отдельное имя: void. Писать его руками не обязательно, компилятор ставит его сам.
Убедиться проще всего трюком из статьи про сужение: присвоить результат переменной заведомо неподходящего типа и прочитать, что назовёт диагностика.
«Тип "void" не может быть назначен для типа "null"» — вот и ответ: возврат вывелся как void, хотя никакой аннотации мы не писали. А на исполнении в check всё-таки лежит undefined — разницу между этими двумя ответами разбирает следующий раздел.
Аннотацию : void ставят, когда хотят закрепить намерение: «эта функция работает ради побочного эффекта, смотреть на её возврат незачем». Нарушить обещание прямо в теле функции нельзя.
TS2322, «Тип "number" не может быть назначен для типа "void"», на строке с return sum. Диагностика есть, но, как всегда, ничего не отменяет: код выполнился, шестёрка напечаталась, а сама функция вернула её вызывающему — типы до браузера не доезжают.
Способов честно вернуть void ровно три, и все три равноправны.
«Функция ничего не возвращает» — неточность. Возвращает: undefined. void говорит другое — что на этот возврат не надо смотреть.
На исполнении разницы между ними нет никакой: и void-функция, и функция с return undefined отдают одно и то же значение. В типах разница есть, и она односторонняя.
Печатается undefined, а присвоить это же самое значение переменной типа undefined компилятор не даёт: TS2322, «Тип "void" не может быть назначен для типа "undefined"».
Причина в смысле, а не в значении. undefined — обычный тип с ровно одним значением, и это значение вы вправе разглядывать. void — пометка «возврат есть, но он не для вас»; сегодня там undefined, а завтра автор функции решит что-то возвращать, и ваш код не должен от этого зависеть. Поэтому void не пускают никуда, где ждут конкретное значение.
Две диагностики: void не лезет даже в string | undefined, где место под undefined явно есть, — а null в переменную типа void не лезет тем более. Обратное направление разрешено: undefined в void кладётся молча.
void | undefined | |
|---|---|---|
| Что говорит про возврат | возврат игнорируют, его смысл не оговорён | функция возвращает именно значение undefined |
Принимает undefined | да | да |
Принимает null при strict | нет, TS2322 | нет, TS2322 |
Результат кладётся в переменную типа undefined | нет, TS2322 | да |
| Можно проверить на истинность | нет, TS1345 | да, диагностики не будет |
Последняя строка называет диагностику, которой в прошлых статьях не встречалось, — TS1345. Она ждёт вас в разделе про потерянный возврат, и там же станет понятно, почему запрет разумный.
В самом языке есть унарный оператор void: он вычисляет выражение и всегда отдаёт undefined. Записи void 0 и undefined дают одно и то же — приём остался со времён, когда undefined можно было переопределить как обычную переменную.
К типу void оператор отношения не имеет: одно слово, две разные вещи, и живут они в разных мирах — оператор в значениях, тип в аннотациях.
Теперь главная странность. Соберём удвоенные числа привычным способом — через forEach и push.
Ни одной диагностики — и это удивительнее, чем кажется. push возвращает не undefined, а новую длину массива, то есть число. Стрелка без фигурных скобок этот результат отдаёт наружу. А в стандартной библиотеке forEach объявлен так: (value: T, index: number, array: T[]) => void. Число вместо void — и тишина.
Правило: если в типе слота стоит возврат void, туда разрешено класть функцию с любым возвратом. Значение просто выбросят. Работает это только тогда, когда тип приходит из контекста — из аннотации переменной, из сигнатуры параметра, из алиаса. Аннотация, написанная прямо на самой функции, остаётся обещанием, и его проверяют.
Четыре объявления ниже отличаются мелочами. Прежде чем запускать, отметьте, на каких строках будет диагностика.
one возвращает строку в слот с void — можно, тип пришёл из Job. four возвращает ровно ту же строку — нельзя, потому что : void написано прямо на стрелке. У three ошибка встречная: функция без возврата попала в слот, который ждёт строку, — про это во врезке. Печатаются только two и three: one и four возвращают строку, но их результат никто не читает.Зачем понадобилось послабление, видно из первого примера: без него doubled.push(n * 2) пришлось бы заворачивать в фигурные скобки только ради того, чтобы выбросить возврат. И так с каждым методом, который принимает колбэк «для побочного эффекта», — правило избавляет от этого налога всю стандартную библиотеку сразу.
Правило одностороннее. Функцию с возвратом можно положить в void-слот, но не наоборот: если слот ждёт значение, а функция его не даёт, это ошибка.
Диагностика приходит матрёшкой: сначала «Тип "() => void" не может быть назначен для типа "() => number"», затем вложенная причина — «Тип "void" не может быть назначен для типа "number"». Запуск подтверждает: в консоли undefined, а не число.
Послабление касается только возврата. Параметры живут по обычным строгим правилам: пропустить лишний в колбэке можно, а объявить лишний — нет.
У послабления есть цена. «Возврат игнорируют» — это не «компилятор перестал следить»: значение действительно возвращается и на исполнении доступно, но тип выражения теперь void, и воспользоваться им не выйдет.
Самый частый случай — алиас для проверки, в котором забыли написать возврат.
TS1345, «Выражение типа "void" не может быть проверено на истинность». Обиднее всего, что код совершенно правильный: запустите — и в консоли окажется ["рико", "код"], потому что стрелка и вправду возвращает boolean. Тип потерялся в момент присваивания в Check, и вернуть его на месте использования нечем.
Лечится это не приведением типа, а исправлением алиаса: type Check = (value: string) => boolean. Правило простое — void в типе колбэка означает «результат мне не нужен». Если он нужен, так и пишите.
Вторая половина той же беды — перепутать forEach с map. У forEach не только колбэк объявлен через void: он и сам возвращает void.
«Тип "void" не может быть назначен для типа "number[]"» — аннотация сработала как ловушка для опечатки. Обратите внимание, что про сам колбэк (score) => score * 2 компилятор не сказал ничего: по правилу из прошлого раздела он имеет полное право возвращать число.
Без : number[] та же ошибка становится совершенно немой: тип переменной выведется как void, диагностики не будет, а в консоли окажется undefined.
Отсюда практический вывод: аннотация на переменной, которая принимает результат чужого вызова, — дешёвая страховка. Выводу типов она ничего не добавляет, зато превращает молчаливый undefined в диагностику на нужной строке.
У асинхронной функции без возврата тип не void, а Promise<void>: значение всё-таки есть — промис, и его можно дождаться. Правило из третьего раздела этой разницы не замечает, потому что не различает, что именно кладут в void-слот.
Порядок вывода — весь урок целиком: сначала «все задачи запущены», и только потом «задача выполнена». Диагностик нет ни одной. Промис, который вернула асинхронная стрелка, лёг в слот () => void и был выброшен; runAll вернулся, не дождавшись ничего, а ошибка внутри такой задачи стала бы необработанным отказом промиса.
В стандартной библиотеке эта история встречается на каждом шагу.
Пустой массив, и снова ни слова от компилятора. forEach обошёл все три элемента, запустил три асинхронные функции, получил от каждой промис, выбросил его — и вернул управление раньше, чем отработал первый await. Заполнится prices позже, когда его уже никто не читает.
Починка не в том, чтобы уговорить forEach ждать, — он не умеет. Нужен метод, чей колбэк возвращает значение.
У map в сигнатуре стоит => U, а не => void: возврат колбэка становится частью типа результата, и промисы никуда не деваются. Обычный for..of с await внутри годится не хуже — он ждёт по очереди, Promise.all запускает всё сразу. Такие места умеет находить и линтер: правило no-misused-promises ловит forEach с асинхронной стрелкой, подробнее о нём — в статье «typescript-eslint: правила, которых нет у компилятора».
Правило на каждый день: увидели async-стрелку в аргументе — проверьте по сигнатуре, ждёт ли её тот, кому вы её отдаёте.
Если ждать нужно, это пишут в типе. Тогда компилятор начинает следить в обе стороны — и синхронную функцию в такой слот уже не пустит.
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. Почините так, чтобы диагностика исчезла, а поведение осталось прежним. Тело стрелки трогать не нужно.
Ошибка не в проверке и не в стрелке, а в алиасе: void объявляет, что возврат никому не нужен, — компилятор так и поступил. Раз результат нужен, его надо назвать.
С честным типом функция становится обычным предикатом, и её можно отдать прямо в filter — цикл больше не нужен. Приведение вида as unknown as boolean здесь было бы худшим из решений: оно спрятало бы диагностику, оставив тип неверным.
Цены не собрались
Функция price возвращает цену асинхронно. Сбор цен написан через forEach — компилятор молчит, а в консоли пусто. Соберите все три цены и напечатайте их.
Колбэк forEach объявлен с возвратом void, поэтому промис асинхронной стрелки выброшен, и метод возвращается раньше первого await. Нужен метод, который возврат колбэка сохраняет.
Вариант с for..of и await внутри тоже верен и читается проще, но ждёт цены по очереди; Promise.all запускает все запросы сразу. Обратите внимание, что тип возврата main — Promise<void>, а не void: значение есть, просто оно пустое.
Сколько будет диагностик
Метод write в интерфейсе объявлен с возвратом void, реализация возвращает длину сообщения, а вызывающий код эту длину читает. Прежде чем запускать, скажите, сколько будет диагностик и на каких строках.
Диагностика одна, на строке 10. Реализация метода претензий не вызывает: тип Logger пришёл из контекста, значит возврат number вместо void разрешён — это то же правило, что спасает forEach с push. А вот на месте вызова у выражения тип void, и в number он не укладывается: TS2322.
На исполнении при этом напечатается 6 — значение возвращается по-настоящему. Если длина сообщения действительно нужна вызывающему, править надо интерфейс:
Полезная привычка: увидев void в чужом интерфейсе, не рассчитывайте на возврат, даже если конкретная реализация что-то отдаёт. Автор типа явно сказал, что это не часть договора и завтра может измениться.