The short answer
Type the handle as ReturnType<typeof setTimeout>. It is whatever setTimeout returns in your project, so the same code checks under Node.js types and browser types:
/** @type {ReturnType<typeof setTimeout> | undefined} */
let pending;
/**
* @param {() => void} save
* @param {number} [delay]
*/
export function scheduleSave(save, delay = 500) {
if (pending !== undefined) clearTimeout(pending);
pending = setTimeout(() => {
pending = undefined;
save();
}, delay);
}
scheduleSave(() => console.log("saved"));let pending: ReturnType<typeof setTimeout> | undefined;
export function scheduleSave(save: () => void, delay = 500): void {
if (pending !== undefined) clearTimeout(pending);
pending = setTimeout(() => {
pending = undefined;
save();
}, delay);
}
scheduleSave(() => console.log("saved"));jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": ["node"]
},
"include": ["*.js"]
} Why it works
Two different setTimeout declarations exist. TypeScript’s DOM library declares the browser’s, which returns a number. @types/node declares Node.js’s, which returns a NodeJS.Timeout object (with methods such as unref()). ReturnType<typeof setTimeout> asks the checker for the return type of the declaration your config loads, so you never have to choose.
The same thing in TypeScript
The TypeScript equivalent tab writes let pending: ReturnType<typeof setTimeout> | undefined, the same type in annotation position.
What goes wrong
In a project with @types/node, a number annotation does not fit the Timeout object (TS2322):
/** @type {number | undefined} */
let pending;
/** @param {() => void} save */
export function scheduleSave(save) {
if (pending !== undefined) clearTimeout(pending);
pending = setTimeout(save, 500);error TS2322: Type 'Timeout' is not assignable to type 'number'.
}jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": ["node"]
},
"include": ["*.js"]
} The same annotation passes in a browser-only project, which is why code copied between the two breaks:
/** @type {number | undefined} */
let pending;
/** @param {() => void} save */
export function scheduleSave(save) {
if (pending !== undefined) clearTimeout(pending);
pending = setTimeout(save, 500);
}jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"lib": ["es2022", "dom", "dom.iterable"],
"types": []
},
"include": ["*.js"]
} Gotchas
- Projects with both type sets get Node.js’s timers. Tests that load
@types/nodealongside the DOM library see theTimeoutreturn type;ReturnType<typeof setTimeout>follows that too. window.setTimeoutis always the browser’s. Calling it throughwindowreturns anumbereven when@types/nodeis loaded, a common way to opt into numbers in front-end code.unref()is Node.js only. TheTimeoutobject’sunref()lets the process exit while the timer is pending; browsers have no equivalent.
Questions
Can I still use the timer as a number in Node.js?
A Node.js Timeout object converts to a number (+timer gives its id), and clearTimeout accepts either. Keeping the handle as ReturnType<typeof setTimeout> avoids the conversion altogether.
What about setInterval?
The same pattern: ReturnType<typeof setInterval>, cleared with clearInterval. In Node.js both return Timeout objects.
Sources
- Timers (setTimeout and class Timeout) · Node.js documentation
- setTimeout() · MDN Web Docs
- Utility Types (ReturnType) · TypeScript handbook
Last reviewed 21 October 2026.