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.
Installation
Section titled “Installation”npm install eslint-plugin-awaitly --save-devpnpm add -D eslint-plugin-awaitlyyarn add --dev eslint-plugin-awaitlybun add --dev eslint-plugin-awaitlyQuick setup (ESLint v9)
Section titled “Quick setup (ESLint v9)”import awaitly from 'eslint-plugin-awaitly';
export default [ ...awaitly.configs.recommended, // your other configs];awaitly/no-immediate-execution
Section titled “awaitly/no-immediate-execution”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/retriesstep('fetchUser', fetchUser('1'));step('fetchUser', deps.fetchUser('1'), { key: 'user:1' });
// Good - thunk lets step control executionstep('fetchUser', () => fetchUser('1'));step('fetchUser', () => deps.fetchUser('1'), { key: 'user:1' });awaitly/require-step-id
Section titled “awaitly/require-step-id”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 IDstep(() => fetchUser('1'));step.parallel({ a: () => fetchA(), b: () => fetchB() });saga.step(() => deps.createOrder(), { compensate: () => {} });
// Good - string ID as first argumentstep('fetchUser', () => fetchUser('1'));step.parallel('Fetch data', () => allAsync([fetchA(), fetchB()]));saga.step('createOrder', () => deps.createOrder(), { compensate: () => {} });awaitly/require-thunk-for-key
Section titled “awaitly/require-thunk-for-key”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 thunkstep('fetchUser', fetchUser('1'), { key: 'user:1' });
// Good - thunk enables caching with keystep('fetchUser', () => fetchUser('1'), { key: 'user:1' });awaitly/stable-cache-keys
Section titled “awaitly/stable-cache-keys”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 hitsstep('fetchUser', () => fetch(id), { key: `user:${Date.now()}` });step('fetchUser', () => fetch(id), { key: `user:${Math.random()}` });
// Good - stable key enables cachingstep('fetchUser', () => fetch(id), { key: `user:${userId}` });awaitly/no-floating-workflow
Section titled “awaitly/no-floating-workflow”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 handlingrun(async ({ step }) => { ... });
// Good - properly handledawait run(async ({ step }) => { ... });const result = await run(async ({ step }) => { ... });return run(async ({ step }) => { ... });awaitly/no-floating-result
Section titled “awaitly/no-floating-result”Severity: error | Autofix: No
Detects step() calls whose Result is discarded without handling. Results should be assigned or their status checked.
// Bad - Result ignoredstep('fetchUser', () => fetchUser());await step('fetchUser', () => fetchUser());
// Good - Result capturedconst result = await step('fetchUser', () => fetchUser());return step('fetchUser', () => fetchUser());awaitly/require-result-handling
Section titled “awaitly/require-result-handling”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 Errconst result = await run(...);console.log(result.value);
// Good - check .ok firstconst result = await run(...);if (result.ok) { console.log(result.value);}
// Good - early return patternconst result = await run(...);if (!result.ok) { return result;}console.log(result.value); // Safe after guardawaitly/no-options-on-executor
Section titled “awaitly/no-options-on-executor”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 ignoredconst workflow = createWorkflow('w', deps, { cache: new Map() });await workflow.run(async ({ step }) => { ... });
// Good - options passed to createWorkflowconst workflow = createWorkflow('workflow', deps, { cache: new Map() });await workflow.run(async ({ step, deps }) => { ... });awaitly/no-double-wrap-result
Section titled “awaitly/no-double-wrap-result”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 Resultrun(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 valuerun(async ({ step }) => { const user = await step('fetchUser', () => fetchUser('1')); return { user };});createWorkflow('workflow', deps).run(async ({ step, deps }) => value);Why these rules?
Section titled “Why these rules?”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.
Custom configuration
Section titled “Custom configuration”To enable only specific rules:
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', }, },];Editor integration
Section titled “Editor integration”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:
{ "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }}