Типовая ошибка setTimeout в TypeScript: window vs Node.js

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

«Не тот setTimeout»: как TypeScript сводит с ума разработчиков

Вы когда-нибудь писали простой таймер на TypeScript, а редактор кода вдруг начинал подчёркивать всё красным? Знакомая картина: вы импортируете setTimeout, пишете const timer = setTimeout(...), а TypeScript выдаёт ошибку или подсказывает непонятный тип. В чём дело? Оказывается, у setTimeout есть две версии — одна для браузера (window), другая для сервера (Node.js). И TypeScript, будучи перфекционистом, заставляет нас выбирать.

Эта тема — одна из самых популярных на Stack Overflow. Тысячи разработчиков спотыкаются об одну и ту же граблю. Но мы не будем просто копировать ответ. Мы разберёмся в корне проблемы, поймём, как работает типизация, и научимся писать код, который не сломается ни в браузере, ни в Node.js.

Почему две версии setTimeout?

Исторически сложилось так, что JavaScript живёт в двух мирах: клиентском (браузер) и серверном (Node.js). В браузере setTimeout — это метод глобального объекта window. В Node.js — это функция из глобального модуля timers. Они делают одно и то же, но их сигнатуры типов отличаются.

Вот ключевые различия:

  • Возвращаемое значение: в браузере window.setTimeout возвращает number (идентификатор таймера). В Node.js — объект NodeJS.Timeout.
  • Контекст: в браузере this внутри callback — это window (в нестрогом режиме). В Node.js — объект таймера.
  • Типизация: TypeScript предоставляет разные декларации для разных сред. Если вы пишете универсальный код (изоморфное приложение), возникает конфликт.

Как выглядит проблема в коде

Представьте, что вы разрабатываете библиотеку, которая должна работать и на клиенте, и на сервере. Вы пишете такой код:

// utils.ts
export function delay(ms: number): Promise<void> {
    return new Promise(resolve => {
        const timer = setTimeout(resolve, ms);
        // Какой тип у timer? number или NodeJS.Timeout?
    });
}

TypeScript увидит, что setTimeout может быть двух видов, и выдаст ошибку: "Type 'Timeout' is not assignable to type 'number'." Или наоборот, в зависимости от настроек tsconfig.json и подключенных типов.

Чтобы было нагляднее, вот как выглядят типы, если посмотреть на них через ReturnType:

// В браузере (lib.dom.d.ts)
type BrowserTimer = ReturnType<typeof window.setTimeout>; // number

// В Node.js (@types/node)
type NodeTimer = ReturnType<typeof setTimeout>; // NodeJS.Timeout

Правильные решения для разных сценариев

Давайте рассмотрим четыре подхода. Выбирайте тот, который подходит под ваш проект.

1. Если вы пишете только для браузера

Самый простой случай. Просто укажите тип number и используйте window.setTimeout явно:

// browser-timer.ts
const timerId: number = window.setTimeout(() => {
    console.log('Прошла секунда');
}, 1000);

// Для очистки
window.clearTimeout(timerId);

Почему это работает? В браузерном окружении window.setTimeout всегда возвращает число. TypeScript это знает, если у вас подключена lib: ["dom"].

2. Если вы пишете только для Node.js

Здесь тип — NodeJS.Timeout. Используйте его напрямую:

// node-timer.ts
import { setTimeout } from 'timers'; // явный импорт для ясности

const timer: NodeJS.Timeout = setTimeout(() => {
    console.log('Таймер сработал');
}, 2000);

clearTimeout(timer); // clearTimeout принимает NodeJS.Timeout

Важно: В Node.js clearTimeout тоже принимает NodeJS.Timeout. Если вы случайно передадите число, TypeScript укажет на ошибку.

3. Универсальный код (изоморфный)

Самый сложный случай. Нужно написать код, который работает везде. Используем ReturnType<typeof setTimeout>:

// universal-timer.ts
// Этот код скомпилируется и в браузере, и в Node.js

type TimerHandle = ReturnType<typeof setTimeout>;

export function createTimer(callback: () => void, ms: number): TimerHandle {
    return setTimeout(callback, ms);
}

export function clearTimer(handle: TimerHandle): void {
    clearTimeout(handle);
}

// Использование
const myTimer = createTimer(() => console.log('Универсальный таймер'), 500);
clearTimer(myTimer);

Как это работает? TypeScript выводит тип TimerHandle на основе текущего окружения. В браузере это будет number, в Node.js — NodeJS.Timeout. Ваш код остаётся корректным в обоих случаях.

4. Кастомный тип через перегрузку

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

// overloaded-timer.ts
// Перегрузки для разных сред
function safeSetTimeout(callback: () => void, ms: number): number;
function safeSetTimeout(callback: () => void, ms: number, ...args: any[]): NodeJS.Timeout;
function safeSetTimeout(callback: () => void, ms: number, ...args: any[]): any {
    return setTimeout(callback, ms, ...args);
}

// Использование
const browserTimer = safeSetTimeout(() => {}, 100); // тип number
const nodeTimer = safeSetTimeout(() => {}, 100, 'arg'); // тип NodeJS.Timeout

Этот подход даёт максимальный контроль, но требует больше кода. Используйте его, только если без перегрузок не обойтись.

Почему не стоит использовать any

Некоторые разработчики, устав от ошибок типов, пишут:

const timer: any = setTimeout(...);

Это плохая практика. Вы теряете все преимущества TypeScript: автодополнение, проверку типов, защиту от опечаток. any — это как выключить систему безопасности в автомобиле. Лучше потратить 5 минут на правильную типизацию, чем потом искать баги в продакшене.

Как настроить tsconfig.json для избежания проблем

Часто путаница возникает из-за неправильной конфигурации TypeScript. Вот что нужно проверить: