Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions docs/rules/explicit-timer-delay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Enforce or disallow explicit `delay` argument for `setTimeout()` and `setInterval()`

💼 This rule is enabled in the following [configs](https://github.com/sindresorhus/eslint-plugin-unicorn#recommended-config): ✅ `recommended`, ☑️ `unopinionated`.

🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).

<!-- end auto-generated rule header -->
<!-- Do not manually modify this header. Run: `npm run fix:eslint-docs` -->

When using [`setTimeout()`](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout) or [`setInterval()`](https://developer.mozilla.org/en-US/docs/Web/API/setInterval), the `delay` parameter is optional and defaults to `0`. This rule allows you to enforce whether the `delay` argument should always be explicitly provided or omitted when it's `0`.

This provides flexibility for different team preferences:

- `"always"` (default): Require explicit `delay` argument for clarity
- `"never"`: Disallow explicit `0` delay (prefer implicit default)

## Examples

### With `"always"` (default)

```js
// ❌
setTimeout(() => console.log('Hello'));
setInterval(callback);
window.setTimeout(() => console.log('Hello'));
globalThis.setInterval(callback);

// ✅
setTimeout(() => console.log('Hello'), 0);
setInterval(callback, 0);
setTimeout(() => console.log('Hello'), 1000);
window.setTimeout(() => console.log('Hello'), 0);
globalThis.setInterval(callback, 100);
```

### With `"never"`

```js
// ❌
setTimeout(() => console.log('Hello'), 0);
setInterval(callback, 0);
window.setTimeout(() => console.log('Hello'), 0);
globalThis.setInterval(callback, 0);

// ✅
setTimeout(() => console.log('Hello'));
setInterval(callback);
setTimeout(() => console.log('Hello'), 1000);
window.setTimeout(() => console.log('Hello'));
globalThis.setInterval(callback, 100);
```

## Options

Type: `string`

Values: `"always"` (default) | `"never"`

### `"always"`

Requires an explicit `delay` argument for all `setTimeout()` and `setInterval()` calls.

```json
{
"unicorn/explicit-timer-delay": ["error", "always"]
}
```

### `"never"`

Disallows an explicit `delay` argument when it's `0`. Non-zero delays are still allowed.

```json
{
"unicorn/explicit-timer-delay": ["error", "never"]
}
```
1 change: 1 addition & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export default [
| [escape-case](docs/rules/escape-case.md) | Require escape sequences to use uppercase or lowercase values. | ✅ ☑️ | 🔧 | |
| [expiring-todo-comments](docs/rules/expiring-todo-comments.md) | Add expiration conditions to TODO comments. | ✅ ☑️ | | |
| [explicit-length-check](docs/rules/explicit-length-check.md) | Enforce explicitly comparing the `length` or `size` property of a value. | ✅ | 🔧 | 💡 |
| [explicit-timer-delay](docs/rules/explicit-timer-delay.md) | Enforce or disallow explicit `delay` argument for `setTimeout()` and `setInterval()`. | ✅ ☑️ | 🔧 | |
| [filename-case](docs/rules/filename-case.md) | Enforce a case style for filenames. | ✅ | | |
| [import-style](docs/rules/import-style.md) | Enforce specific import styles per module. | ✅ ☑️ | | |
| [new-for-builtins](docs/rules/new-for-builtins.md) | Enforce the use of `new` for all builtins, except `String`, `Number`, `Boolean`, `Symbol` and `BigInt`. | ✅ ☑️ | 🔧 | 💡 |
Expand Down
144 changes: 144 additions & 0 deletions rules/explicit-timer-delay.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import {isLiteral} from './ast/index.js';

const MODE_ALWAYS = 'always';
const MODE_NEVER = 'never';

const MESSAGE_ID_MISSING_DELAY = 'missing-delay';
const MESSAGE_ID_REDUNDANT_DELAY = 'redundant-delay';

const messages = {
[MESSAGE_ID_MISSING_DELAY]: '`{{name}}` should have an explicit delay argument.',
[MESSAGE_ID_REDUNDANT_DELAY]: '`{{name}}` should not have an explicit delay of `0`.',
};

const timerFunctions = new Set(['setTimeout', 'setInterval']);

/**
Check if a call expression is a timer function call.
@param {import('estree').CallExpression} node - The call expression node.
@param {import('eslint').SourceCode} sourceCode
@returns {{isTimer: boolean, name?: string}} Object with isTimer flag and function name.
*/
const checkTimerCall = (node, sourceCode) => {
const {callee} = node;

if (
callee.type === 'Identifier'
&& timerFunctions.has(callee.name)
&& sourceCode.isGlobalReference(callee)
) {
return {isTimer: true, name: callee.name};
}

if (
callee.type === 'MemberExpression'
&& !callee.computed
&& callee.property.type === 'Identifier'
&& timerFunctions.has(callee.property.name)
) {
const {object} = callee;

if (
object.type === 'Identifier'
&& sourceCode.isGlobalReference(object)
) {
return {isTimer: true, name: callee.property.name};
}

return {isTimer: false};
}

return {isTimer: false};
};

/**
Check if the delay argument is explicitly zero.
@param {import('estree').Node} node - The argument node.
@returns {boolean} True if the argument is zero.
*/
const isZeroDelay = node =>
isLiteral(node, 0)
|| (node.type === 'UnaryExpression' && node.operator === '-' && isLiteral(node.argument, 0));

/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const mode = context.options[0] || MODE_ALWAYS;
const {sourceCode} = context;

context.on('CallExpression', node => {
if (node.optional) {
return;
}

const {isTimer, name} = checkTimerCall(node, sourceCode);

if (!isTimer) {
return;
}

const {arguments: arguments_} = node;
const hasDelayArgument = arguments_.length >= 2;

if (mode === MODE_ALWAYS && !hasDelayArgument) {
if (arguments_.length === 0) {
return;
}

const problem = {
node,
messageId: MESSAGE_ID_MISSING_DELAY,
data: {name},
};

const [firstArgument] = arguments_;
if (firstArgument && firstArgument.type !== 'SpreadElement') {
problem.fix = fixer => fixer.insertTextAfter(firstArgument, ', 0');
}

return problem;
}

if (mode === MODE_NEVER && hasDelayArgument) {
const delayArgument = arguments_[1];

if (isZeroDelay(delayArgument)) {
return {
node: delayArgument,
messageId: MESSAGE_ID_REDUNDANT_DELAY,
data: {name},
fix(fixer) {
const firstArgument = arguments_[0];
const [, firstArgumentEnd] = sourceCode.getRange(firstArgument);
const [delayArgumentStart] = sourceCode.getRange(delayArgument);

return fixer.removeRange([firstArgumentEnd, delayArgumentStart + sourceCode.getText(delayArgument).length]);
},
};
}
}
});
};

const schema = [
{
enum: [MODE_ALWAYS, MODE_NEVER],
},
];

/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Enforce or disallow explicit `delay` argument for `setTimeout()` and `setInterval()`.',
recommended: 'unopinionated',
},
fixable: 'code',
schema,
defaultOptions: [MODE_ALWAYS],
messages,
},
};

export default config;
1 change: 1 addition & 0 deletions rules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export {default as 'error-message'} from './error-message.js';
export {default as 'escape-case'} from './escape-case.js';
export {default as 'expiring-todo-comments'} from './expiring-todo-comments.js';
export {default as 'explicit-length-check'} from './explicit-length-check.js';
export {default as 'explicit-timer-delay'} from './explicit-timer-delay.js';
export {default as 'filename-case'} from './filename-case.js';
export {default as 'import-style'} from './import-style.js';
export {default as 'new-for-builtins'} from './new-for-builtins.js';
Expand Down
Loading
Loading