Skip to content

ESLint Plugin

ESLint rules for awaitly workflow patterns. Catch common mistakes automatically with autofixes. The plugin covers all step helpers, including step.withFallback, step.withResource, and step.workflow.

Terminal window
npm install eslint-plugin-awaitly --save-dev
eslint.config.js
import awaitly from 'eslint-plugin-awaitly';
export default [
...awaitly.configs.recommended,
// your other configs
];

Severity: error | Autofix: Yes

Prevents step('id', fn()) patterns where the function executes immediately instead of being wrapped in a thunk.

// Bad - executes immediately, defeats caching/retries
step('fetchUser', fetchUser('1'));
step('fetchUser', deps.fetchUser('1'), { key: 'user:1' });
// Good - thunk lets step control execution
step('fetchUser', () => fetchUser('1'));
step('fetchUser', () => deps.fetchUser('1'), { key: 'user:1' });

Severity: error | Autofix: No

Requires a string literal as the first argument to step() and step helpers (step.sleep, step.retry, step.parallel, step.race, step.withFallback, step.withResource, step.workflow, etc.). Ensures stable step IDs for visualization and static analysis.

// Bad - missing step ID
step(() => fetchUser('1'));
step.parallel({ a: () => fetchA(), b: () => fetchB() });
saga.step(() => deps.createOrder(), { compensate: () => {} });
// Good - string ID as first argument
step('fetchUser', () => fetchUser('1'));
step.parallel('Fetch data', () => allAsync([fetchA(), fetchB()]));
saga.step('createOrder', () => deps.createOrder(), { compensate: () => {} });

Severity: error | Autofix: Yes

When using step() with a key option, the second argument must be a thunk. Without a thunk, the cache is never checked.

// Bad - key option is useless without thunk
step('fetchUser', fetchUser('1'), { key: 'user:1' });
// Good - thunk enables caching with key
step('fetchUser', () => fetchUser('1'), { key: 'user:1' });

Severity: error | Autofix: No

Prevents non-deterministic values like Date.now(), Math.random(), or uuid() in cache keys.

// Bad - new key every time, cache never hits
step('fetchUser', () => fetch(id), { key: `user:${Date.now()}` });
step('fetchUser', () => fetch(id), { key: `user:${Math.random()}` });
// Good - stable key enables caching
step('fetchUser', () => fetch(id), { key: `user:${userId}` });

Severity: error | Autofix: No

Detects run() calls that are not awaited, returned, or assigned. Floating workflows execute asynchronously without any way to handle their results.

// Bad - fire-and-forget, no error handling
run(async ({ step }) => { ... });
// Good - properly handled
await run(async ({ step }) => { ... });
const result = await run(async ({ step }) => { ... });
return run(async ({ step }) => { ... });

Severity: error | Autofix: No

Detects step() calls whose Result is discarded without handling. Results should be assigned or their status checked.

// Bad - Result ignored
step('fetchUser', () => fetchUser());
await step('fetchUser', () => fetchUser());
// Good - Result captured
const result = await step('fetchUser', () => fetchUser());
return step('fetchUser', () => fetchUser());

Severity: warn | Autofix: No

Warns when accessing .value on a Result without checking .ok first. This prevents runtime errors when the Result is an Err.

// Bad - could throw if result is Err
const result = await run(...);
console.log(result.value);
// Good - check .ok first
const result = await run(...);
if (result.ok) {
console.log(result.value);
}
// Good - early return pattern
const result = await run(...);
if (!result.ok) {
return result;
}
console.log(result.value); // Safe after guard

Severity: error | Autofix: No

Detects when workflow options (cache, onEvent, resumeState, etc.) are passed to the workflow executor function instead of createWorkflow(). Options passed to the executor are silently ignored.

// Bad - options are ignored
const workflow = createWorkflow('w', deps, { cache: new Map() });
await workflow.run(async ({ step }) => { ... });
// Good - options passed to createWorkflow
const workflow = createWorkflow('workflow', deps, { cache: new Map() });
await workflow.run(async ({ step, deps }) => { ... });

Severity: error | Autofix: No

Detects returning ok() or err() from workflow executor functions. Executors should return raw values; awaitly wraps them automatically.

// Bad - double-wrapped Result
run(async ({ step }) => {
const user = await step('fetchUser', () => fetchUser('1'));
return ok({ user });
});
createWorkflow('workflow', deps).run(async ({ step, deps }) => err({ type: 'FAILED' }));
// Good - return raw value
run(async ({ step }) => {
const user = await step('fetchUser', () => fetchUser('1'));
return { user };
});
createWorkflow('workflow', deps).run(async ({ step, deps }) => value);

The #1 mistake with awaitly is forgetting the thunk:

// This looks correct but is wrong:
const user = await step('fetchUser', () => fetchUser('1'), { key: 'user:1' });

The function fetchUser('1') executes immediately when JavaScript evaluates this line. The step() function receives the Promise (already started), not a function it can call. This defeats:

  • Caching: step can’t check the cache before calling
  • Retries: step can’t re-call on failure
  • Resume: step can’t skip already-completed work

The correct pattern:

const user = await step('fetchUser', () => fetchUser('1'), { key: 'user:1' });

Now step() receives a function it can call after checking the cache.

To enable only specific rules:

eslint.config.js
import awaitly from 'eslint-plugin-awaitly';
export default [
{
plugins: {
awaitly,
},
rules: {
'awaitly/require-step-id': 'error',
'awaitly/no-immediate-execution': 'error',
'awaitly/require-thunk-for-key': 'error',
'awaitly/stable-cache-keys': 'error',
'awaitly/no-floating-workflow': 'error',
'awaitly/no-floating-result': 'error',
'awaitly/require-result-handling': 'warn',
'awaitly/no-options-on-executor': 'error',
'awaitly/no-double-wrap-result': 'error',
},
},
];

With ESLint editor extensions, you’ll see errors inline and can apply autofixes:

  • VS Code: ESLint extension
  • Cursor: ESLint support built-in
  • WebStorm/IntelliJ: ESLint support built-in

Enable “fix on save” for automatic corrections:

.vscode/settings.json
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}