Skip to content

ESLint Plugin

ESLint rules for awaitly workflow patterns. Every rule is named with its canonical slug — the same identifier that appears on runtime errors (error.code), in the awaitly-analyze --doctor output, and as the URL slug for the rule’s docs page (jagreehal.github.io/awaitly/rules/#<slug>). One token, four surfaces.

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
];

For projects that want hints upgraded to errors (CI gating), use recommended-strict:

import awaitly from 'eslint-plugin-awaitly';
export default [
...awaitly.configs['recommended-strict'],
];

Twenty rules in six categories. The category prefix matches the canonical slug namespace.

Severity: error • Autofix: yes

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

// Bad — fn() runs synchronously, 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

Requires a string literal as the first argument to step() and step helpers (step.sleep, step.retry, step.all, step.race, step.withFallback, step.withResource, step.workflow, …). Stable IDs are needed for visualization, caching, and resume.

// Bad — missing or computed step ID
step(() => fetchUser('1'));
step(`user-${i}`, () => deps.fetchUser(i));
step.all({ a: () => fetchA(), b: () => fetchB() });
// Good — string literal ID; use { key } for per-item identity
step('fetchUser', () => fetchUser('1'));
step('fetchUser', () => deps.fetchUser(i), { key: `user:${i}` });
step.all('fetch', () => allAsync([fetchA(), fetchB()]));

Severity: error • Autofix: yes

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

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

Severity: error

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

// Bad — new key every call, cache never hits
step('fetchUser', () => fetch(id), { key: `user:${Date.now()}` });
// Good — stable identifier
step('fetchUser', () => fetch(id), { key: `user:${userId}` });

Severity: error

Disallows await deps.fn() outside a step() wrapper. Bare awaits skip the workflow’s caching, retries, and resume.

// Bad — bypasses step semantics
const user = await deps.fetchUser('1');
// Good — wrapped in step
const user = await step('fetchUser', () => deps.fetchUser('1'));

Severity: error

Disallows wrapping step() in try/catch. Use step.try() for typed throw-to-error conversion.

// Bad
try { await step('charge', () => deps.charge(amount)); } catch (e) { /* */ }
// Good
const charge = await step.try('charge', () => deps.charge(amount), {
error: (cause) => ({ type: 'CHARGE_THREW', cause }),
});

Severity: error

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

// Bad — fire-and-forget
run(async ({ step }) => { /* */ });
// Good
await run(async ({ step }) => { /* */ });

Severity: error

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

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

Severity: warn

Workflow callbacks should destructure their context. Only step is required; deps and ctx are optional depending on the entry point.

// Bad — opaque parameter
workflow.run(async (ctx) => { /* */ });
// Good — any of these
workflow.run(async ({ step }) => { /* */ });
workflow.run(async ({ step, deps }) => { /* */ });
workflow.run(async ({ step, deps, ctx }) => { /* */ });

Severity: error

The workflow callable form (workflow(callback)) is not supported. Always use workflow.run(...). Detection narrows on the step destructure to avoid false positives on generic callback patterns.

// Bad
workflow(async ({ step }) => { /* */ });
// Good
workflow.run(async ({ step }) => { /* */ });

Severity: error

Disallows dynamic import() and require() so static analysis can trace dependencies.

Severity: error

Detects step() calls whose Result is discarded. Results should be assigned, returned, or have .ok checked.

// Bad
step('fetchUser', () => fetchUser());
// Good
const user = await step('fetchUser', () => fetchUser());

Severity: warn

Warns when accessing .value on a Result without checking .ok first.

// Bad
const result = await run(/* */);
console.log(result.value);
// Good
const result = await run(/* */);
if (result.ok) console.log(result.value);

Severity: error

Detects returning ok() or err() from workflow callbacks. The workflow wraps the return value automatically.

// Bad
run(async ({ step }) => {
const user = await step('fetchUser', () => fetchUser('1'));
return ok({ user });
});
// Good
run(async ({ step }) => {
const user = await step('fetchUser', () => fetchUser('1'));
return { user };
});

Severity: error

Disallows return ok(...) / return err(...) inside a workflow callback. The rule scope-guards on workflow callbacks — deps functions and step thunks are unaffected.

// Bad — manual propagation in workflow callback
workflow.run(async ({ step }) => {
return ok({ id: 1 });
});
// Fine — deps function returning a Result
async function fetchUser(): AsyncResult<User, 'NOT_FOUND'> {
return ok({ id: 1 });
}

Severity: error

Disallows ok() / err() calls inside a workflow callback. Same scope guard as result-no-manual-propagation. Step thunks and deps functions can still call ok/err.

Severity: error

Replace Promise.all inside workflows with step.all() / step.map().

Severity: error

Replace Promise.race with step.race().

Severity: error

Replace Promise.allSettled with step.map() (which collects per-item Results without fail-fast).

Severity: warn (opt-in — not in recommended)

When matching on result.error._tag / .type, check isUnexpectedError(result.error) first to separate library bugs from typed business errors.

This rule uses heuristic AST matching and may false-positive on patterns it can’t statically distinguish. It’s deliberately not enabled by recommended or recommended-strict; opt in by adding it to your rules block explicitly:

{
rules: {
'awaitly/error-check-unexpected-first': 'warn',
},
}
// Bad
if (result.error._tag === 'TimeoutError') { /* */ }
// Good
if (isUnexpectedError(result.error)) {
log.error('library bug', result.error.cause);
} else if (result.error._tag === 'TimeoutError') {
/* */
}

To enable only specific rules:

eslint.config.js
import awaitly from 'eslint-plugin-awaitly';
export default [
{
plugins: { awaitly },
rules: {
'awaitly/step-require-id': 'error',
'awaitly/step-no-immediate-execution': 'error',
'awaitly/step-require-thunk-for-key': 'error',
'awaitly/step-stable-cache-keys': 'error',
'awaitly/step-no-bare-await': 'error',
'awaitly/step-no-try-catch-wrap': 'error',
'awaitly/workflow-no-floating': 'error',
'awaitly/workflow-options-position': 'error',
'awaitly/workflow-callback-shape': 'warn',
'awaitly/workflow-no-callable-form': 'error',
'awaitly/workflow-no-dynamic-import': 'error',
'awaitly/result-no-floating': 'error',
'awaitly/result-require-handling': 'warn',
'awaitly/result-no-double-wrap': 'error',
'awaitly/result-no-manual-propagation': 'error',
'awaitly/result-no-direct-ok-err': 'error',
'awaitly/concurrency-no-promise-all': 'error',
'awaitly/concurrency-no-promise-race': 'error',
'awaitly/concurrency-no-promise-allsettled': 'error',
'awaitly/error-check-unexpected-first': 'warn',
},
},
];

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"
}
}

The #1 mistake with awaitly is forgetting the thunk:

// Looks correct but is wrong:
const user = await step('fetchUser', deps.fetchUser('1'), { key: 'user:1' });

deps.fetchUser('1') runs immediatelystep() receives the in-flight Promise, not a function it can call. That 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 slug-named rules (step-no-immediate-execution, step-require-thunk-for-key, …) catch this and the other small-but-important mistakes statically — and the slug names match the runtime error codes, so a developer or AI agent can grep one token to find both the lint rule and the runtime error guidance.