Question
I am upgrading older TypeScript code to a newer compiler version and ran into a problem with setTimeout.
My code is intended to run in the browser, where setTimeout returns a number:
setTimeout(handler: (...args: any[]) => void, timeout: number): number;
However, TypeScript is resolving setTimeout to the Node.js version instead, which returns a NodeJS.Timer (or Node-specific timeout object):
setTimeout(callback: (...args: any[]) => void, ms: number, ...args: any[]): NodeJS.Timer;
This code does not run in Node.js, but Node typings are being included indirectly through another dependency.
Here is the code that causes the issue:
let n: number;
n = setTimeout(function () {
/* snip */
}, 500);
TypeScript reports this error:
TS2322: Type 'Timer' is not assignable to type 'number'.
How can I tell TypeScript to use the browser version of setTimeout, or otherwise write this code correctly?
Short Answer
By the end of this page, you will understand why setTimeout has different return types in browser and Node.js environments, why TypeScript sometimes picks the Node version unexpectedly, and how to write timeout code that works safely in browser-only or mixed TypeScript projects.
Concept
TypeScript does not invent APIs like setTimeout; it reads their types from declaration files provided by the active environment.
For setTimeout, the return type depends on where your code is expected to run:
- Browser:
setTimeoutreturns a numeric timer ID. - Node.js:
setTimeoutreturns a timeout object.
This becomes confusing when a project includes both DOM types and Node types. If Node typings are present, TypeScript may treat the global setTimeout as the Node version or as an overloaded function with multiple possible return types.
That is why this code can fail:
let n: number;
n = setTimeout(() => {}, 500);
If TypeScript sees the Node definition, the returned value is not a number, so assignment fails.
This matters in real projects because many frontend builds accidentally include @types/node through tooling, test libraries, or bundler dependencies. Once those types are visible, globals such as setTimeout, clearTimeout, process, and Buffer may affect your type checking.
The main idea is:
- If your code is browser-only, use the browser global explicitly when needed.
- If your code may run in different environments, avoid hardcoding the timer type as
number. - Prefer deriving the type from the function itself with
ReturnType<typeof setTimeout>when you want environment-safe code.
Mental Model
Think of setTimeout as a function with the same name living in two different buildings:
- In the browser building, the receptionist hands you a number ticket.
- In the Node.js building, the receptionist hands you an object pass.
Your code says, "I expect a number ticket," but TypeScript looks around and says, "I also see the Node building, so this might be an object pass instead."
The fix is to either:
- clearly walk into the browser building using
window.setTimeout, or - stop assuming the ticket is always a number and let TypeScript infer the correct return type.
Syntax and Examples
The most common ways to handle this are below.
1. Browser-only code: use window.setTimeout
If your code definitely runs in the browser, reference the browser global directly:
let n: number;
n = window.setTimeout(() => {
console.log("Runs after 500ms");
}, 500);
Why this works
window.setTimeout comes from the DOM typings, so TypeScript uses the browser signature, which returns a number.
2. Environment-safe code: use ReturnType<typeof setTimeout>
If you want code that works whether TypeScript sees browser or Node typings, do this:
let timer: ReturnType<typeof setTimeout>;
timer = setTimeout(() => {
console.log("Runs after 500ms");
}, 500);
Why this works
ReturnType<typeof setTimeout> means:
- "Look at the current
setTimeoutfunction type" - "Use whatever that function returns in this environment"
That makes the variable type adapt automatically.
3. Browser-only code with explicit DOM config
In a frontend project, make sure your tsconfig.json includes the DOM library:
{
"compilerOptions": {
"lib": ["ES2020", "DOM"]
}
}
If Node types are being pulled in accidentally, you can also control which global type packages are included:
{
"compilerOptions": {
"types": []
}
}
Or explicitly list only the ones you want:
{
"compilerOptions": {
"types": ["jest"]
}
}
This can prevent @types/node from affecting browser code.
4. Clearing the timeout
In environment-safe code:
let timer: ReturnType<typeof setTimeout> = setTimeout(() => {
console.log("Hello");
}, 500);
clearTimeout(timer);
This is often better than forcing the type to number.
Step by Step Execution
Consider this example:
let timer: ReturnType<typeof setTimeout>;
timer = setTimeout(() => {
console.log("Done");
}, 1000);
clearTimeout(timer);
Here is what happens step by step:
-
let timer: ReturnType<typeof setTimeout>;- TypeScript checks the current type of
setTimeout. - It computes the type that this function returns.
- In a browser, this is usually
number. - In Node.js, this is usually a timeout object.
- TypeScript checks the current type of
-
timer = setTimeout(() => { ... }, 1000);- A timeout is scheduled.
- The returned handle is stored in
timer. - The assignment is valid because
timerwas typed from the actual function.
-
clearTimeout(timer);- The scheduled timeout is cancelled.
- This also type-checks correctly because
timermatches the environment's timeout handle type.
Now compare with this version:
let timerId: number;
timerId = setTimeout(() => {
console.log("Done");
}, 1000);
If Node typings are visible, TypeScript may say:
setTimeout(...)does not returnnumber- so
timerId: numberis too specific
That is the source of the error.
Real World Use Cases
Timeout handles appear in many common programming tasks:
Delayed UI actions
window.setTimeout(() => {
modal.classList.add("visible");
}, 300);
Used for animations, tooltips, banners, and delayed notifications.
Debouncing user input
let timer: ReturnType<typeof setTimeout>;
function onSearchInput() {
clearTimeout(timer);
timer = setTimeout(() => {
console.log("Send API request");
}, 300);
}
Used in search boxes, validation, and autosave features.
Retrying operations
setTimeout(() => {
console.log("Retry request");
}, 1000);
Used after temporary API failures.
Background job timing in Node.js
In Node, the returned timeout handle is an object rather than a number, but the coding pattern is similar. That is exactly why TypeScript needs to know which environment you mean.
Real Codebase Usage
In real codebases, developers usually use one of these patterns.
Pattern 1: Explicit browser global
Used when code is clearly browser-only:
const id = window.setTimeout(() => {
console.log("browser only");
}, 500);
This is common in UI components, browser widgets, and DOM event handlers.
Pattern 2: Environment-agnostic timer type
Used in shared libraries, React apps, testable utilities, and code that may be compiled with both DOM and Node typings:
let timeoutRef: ReturnType<typeof setTimeout> | null = null;
function scheduleWork() {
if (timeoutRef !== null) {
clearTimeout(timeoutRef);
}
timeoutRef = setTimeout(() => {
console.log("work done");
}, 250);
}
This pattern is especially common for:
- debouncing
- throttling
- delayed validation
- polling
- retry logic
Pattern 3: Guard clauses for cleanup
let timer: ReturnType<typeof setTimeout> | null = null;
function cancelTimer() {
if (timer === null) {
return;
}
clearTimeout(timer);
timer = null;
}
This avoids calling clearTimeout on an uninitialized value.
Pattern 4: Type configuration at project level
Teams often fix this in tsconfig.json rather than in every file. For example:
- browser app: include
DOM - Node service: include Node types
- frontend app with accidental Node globals: restrict
types
That keeps the whole codebase consistent.
Common Mistakes
Mistake 1: Assuming setTimeout always returns a number
Broken code:
let id: number = setTimeout(() => {
console.log("hello");
}, 500);
Why it fails:
- In browser-only typing, this is fine.
- If Node typings are included, the return type may not be
number.
Better:
let id: ReturnType<typeof setTimeout> = setTimeout(() => {
console.log("hello");
}, 500);
Mistake 2: Forcing the type with a cast
Broken or risky code:
let id = setTimeout(() => {}, 500) as number;
Why to avoid it:
- Type assertions silence the error instead of fixing the cause.
- You may hide a real environment mismatch.
Better:
- Use
window.setTimeoutfor browser code. - Use
ReturnType<typeof setTimeout>for portable code.
Mistake 3: Forgetting that clearTimeout should match the same handle type
Broken pattern:
let id: number = setTimeout(() => {}, 500);
clearTimeout(id);
This may fail in projects where the timeout handle is not typed as number.
Better:
let id: ReturnType<typeof setTimeout> = setTimeout(() => {}, 500);
clearTimeout(id);
Mistake 4: Not checking tsconfig.json
If Node types appear unexpectedly, the problem may not be your code at all.
Check:
compilerOptions.libcompilerOptions.types- installed
@types/*packages - toolchain packages that depend on Node typings
Mistake 5: Using window.setTimeout in non-browser code
Broken idea:
const id = window.setTimeout(() => {}, 500);
Why it can fail:
windowdoes not exist in Node.js.- This is only correct when you truly run in a browser environment.
Comparisons
| Approach | Best for | Return type | Pros | Cons |
|---|---|---|---|---|
setTimeout(...) | General use | Depends on visible typings | Short and familiar | Can be ambiguous when both DOM and Node types exist |
window.setTimeout(...) | Browser-only code | number | Explicit, clear, fixes browser typing | Only works in browser environments |
global.setTimeout(...) / Node global | Node-only code | Node timeout object | Explicit for server code | Not for browser code |
ReturnType<typeof setTimeout> | Shared or mixed environments | Derived automatically | Portable and type-safe | Slightly more advanced syntax |
number vs ReturnType<typeof setTimeout>
| Type choice | Good idea when | Not ideal when |
|---|---|---|
number | You are certain the code is browser-only and use DOM APIs | Node typings are visible or code may be shared |
ReturnType<typeof setTimeout> | You want the code to adapt to the environment | You specifically need a browser numeric timer ID |
Project-level vs code-level fix
| Fix | Scope | Example |
|---|---|---|
| Code-level | One file or one variable | window.setTimeout(...) |
| Type-level | One variable pattern | ReturnType<typeof setTimeout> |
| Config-level | Whole project | tsconfig.json lib / types settings |
Cheat Sheet
// Browser-only
let id: number = window.setTimeout(() => {}, 500);
// Portable across browser/Node typings
let timer: ReturnType<typeof setTimeout> = setTimeout(() => {}, 500);
clearTimeout(timer);
Key rules
setTimeoutreturn type depends on the environment.- In browsers, it is usually
number. - In Node.js, it is a timeout object.
- If Node typings are included,
setTimeoutmay no longer type-check as returningnumber.
Best choices
- Use
window.setTimeout(...)for browser-only code. - Use
ReturnType<typeof setTimeout>for shared or mixed code. - Avoid
as numberunless you fully understand the tradeoff.
Useful tsconfig.json settings
{
"compilerOptions": {
"lib": ["ES2020", "DOM"],
"types": []
}
}
Quick diagnosis checklist
- Does your project include
@types/node? - Is
DOMincluded inlib? - Are test tools or build tools pulling in Node globals?
- Are you writing browser-only code or shared code?
Safe pattern for reusable code
let timeoutRef: ReturnType<typeof setTimeout> | null = null;
if (timeoutRef !== null) {
clearTimeout(timeoutRef);
}
timeoutRef = setTimeout(() => {
console.log("work");
}, 300);
FAQ
Why does TypeScript think setTimeout returns a Node type?
Because Node typings are visible in your project, often through @types/node or a dependency that includes them.
How do I force the browser version of setTimeout?
Use window.setTimeout(...) in browser code. This makes the DOM version explicit.
What is the safest type for a timeout handle in TypeScript?
ReturnType<typeof setTimeout> is usually the safest choice because it adapts to the active environment.
Should I type timeout IDs as number?
Only if your code is definitely browser-only and you are intentionally using the DOM version, often as window.setTimeout(...).
Can I remove Node typings from a frontend project?
Yes, often by adjusting compilerOptions.types in tsconfig.json, or by reviewing dependencies that bring in @types/node.
Does clearTimeout also have environment-specific typing?
Yes. That is why using the matching timeout handle type is important.
Is as number a good fix?
Usually no. It hides the type mismatch instead of solving the environment typing problem.
Mini Project
Description
Build a small search-input debounce utility in TypeScript. This project demonstrates the real reason timer handle types matter: you often store a timeout, cancel the previous one, and create a new one when the user keeps typing. This pattern is common in frontend apps and utility libraries.
Goal
Create a debounced function that delays work until the user stops triggering events for a short time.
Requirements
- Create a function that accepts a callback and a delay in milliseconds.
- Store the timeout handle so the previous timeout can be cancelled.
- Use a timeout handle type that works correctly with TypeScript.
- Call the callback only after the delay has passed without a new trigger.
Keep learning
Related questions
@Directive vs @Component in Angular: Differences, Use Cases, and When to Use Each
Learn the difference between @Directive and @Component in Angular, including use cases, examples, and when to choose each.
Accessing Input Value from EventTarget in TypeScript
Learn why EventTarget has no value property in TypeScript and safely read values from HTML input events in Angular applications.
Angular (change) vs (ngModelChange): What’s the Difference?
Learn the difference between Angular (change) and (ngModelChange), when each fires, and which one to use in forms and inputs.