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.
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];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.
step-* — step() discipline
Section titled “step-* — step() discipline”awaitly/step-no-immediate-execution
Section titled “awaitly/step-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 — fn() runs synchronously, 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/step-require-id
Section titled “awaitly/step-require-id”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 IDstep(() => fetchUser('1'));step(`user-${i}`, () => deps.fetchUser(i));step.all({ a: () => fetchA(), b: () => fetchB() });
// Good — string literal ID; use { key } for per-item identitystep('fetchUser', () => fetchUser('1'));step('fetchUser', () => deps.fetchUser(i), { key: `user:${i}` });step.all('fetch', () => allAsync([fetchA(), fetchB()]));awaitly/step-require-thunk-for-key
Section titled “awaitly/step-require-thunk-for-key”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 thunkstep('fetchUser', fetchUser('1'), { key: 'user:1' });
// Good — thunk enables cachingstep('fetchUser', () => fetchUser('1'), { key: 'user:1' });awaitly/step-stable-cache-keys
Section titled “awaitly/step-stable-cache-keys”Severity: error
Prevents non-deterministic values like Date.now(), Math.random(), or uuid() in cache keys.
// Bad — new key every call, cache never hitsstep('fetchUser', () => fetch(id), { key: `user:${Date.now()}` });
// Good — stable identifierstep('fetchUser', () => fetch(id), { key: `user:${userId}` });awaitly/step-no-bare-await
Section titled “awaitly/step-no-bare-await”Severity: error
Disallows await deps.fn() outside a step() wrapper. Bare awaits skip the workflow’s caching, retries, and resume.
// Bad — bypasses step semanticsconst user = await deps.fetchUser('1');
// Good — wrapped in stepconst user = await step('fetchUser', () => deps.fetchUser('1'));awaitly/step-no-try-catch-wrap
Section titled “awaitly/step-no-try-catch-wrap”Severity: error
Disallows wrapping step() in try/catch. Use step.try() for typed throw-to-error conversion.
// Badtry { await step('charge', () => deps.charge(amount)); } catch (e) { /* */ }
// Goodconst charge = await step.try('charge', () => deps.charge(amount), { error: (cause) => ({ type: 'CHARGE_THREW', cause }),});workflow-* — workflow shape
Section titled “workflow-* — workflow shape”awaitly/workflow-no-floating
Section titled “awaitly/workflow-no-floating”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-forgetrun(async ({ step }) => { /* */ });
// Goodawait run(async ({ step }) => { /* */ });awaitly/workflow-options-position
Section titled “awaitly/workflow-options-position”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 ignoredconst workflow = createWorkflow('w', deps);await workflow.run(async ({ step }) => {}, { cache: new Map() });
// Goodconst workflow = createWorkflow('w', deps, { cache: new Map() });await workflow.run(async ({ step }) => {});awaitly/workflow-callback-shape
Section titled “awaitly/workflow-callback-shape”Severity: warn
Workflow callbacks should destructure their context. Only step is required; deps and ctx are optional depending on the entry point.
// Bad — opaque parameterworkflow.run(async (ctx) => { /* */ });
// Good — any of theseworkflow.run(async ({ step }) => { /* */ });workflow.run(async ({ step, deps }) => { /* */ });workflow.run(async ({ step, deps, ctx }) => { /* */ });awaitly/workflow-no-callable-form
Section titled “awaitly/workflow-no-callable-form”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.
// Badworkflow(async ({ step }) => { /* */ });
// Goodworkflow.run(async ({ step }) => { /* */ });awaitly/workflow-no-dynamic-import
Section titled “awaitly/workflow-no-dynamic-import”Severity: error
Disallows dynamic import() and require() so static analysis can trace dependencies.
result-* — Result handling
Section titled “result-* — Result handling”awaitly/result-no-floating
Section titled “awaitly/result-no-floating”Severity: error
Detects step() calls whose Result is discarded. Results should be assigned, returned, or have .ok checked.
// Badstep('fetchUser', () => fetchUser());
// Goodconst user = await step('fetchUser', () => fetchUser());awaitly/result-require-handling
Section titled “awaitly/result-require-handling”Severity: warn
Warns when accessing .value on a Result without checking .ok first.
// Badconst result = await run(/* */);console.log(result.value);
// Goodconst result = await run(/* */);if (result.ok) console.log(result.value);awaitly/result-no-double-wrap
Section titled “awaitly/result-no-double-wrap”Severity: error
Detects returning ok() or err() from workflow callbacks. The workflow wraps the return value automatically.
// Badrun(async ({ step }) => { const user = await step('fetchUser', () => fetchUser('1')); return ok({ user });});
// Goodrun(async ({ step }) => { const user = await step('fetchUser', () => fetchUser('1')); return { user };});awaitly/result-no-manual-propagation
Section titled “awaitly/result-no-manual-propagation”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 callbackworkflow.run(async ({ step }) => { return ok({ id: 1 });});
// Fine — deps function returning a Resultasync function fetchUser(): AsyncResult<User, 'NOT_FOUND'> { return ok({ id: 1 });}awaitly/result-no-direct-ok-err
Section titled “awaitly/result-no-direct-ok-err”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.
concurrency-* — concurrency primitives
Section titled “concurrency-* — concurrency primitives”awaitly/concurrency-no-promise-all
Section titled “awaitly/concurrency-no-promise-all”Severity: error
Replace Promise.all inside workflows with step.all() / step.map().
awaitly/concurrency-no-promise-race
Section titled “awaitly/concurrency-no-promise-race”Severity: error
Replace Promise.race with step.race().
awaitly/concurrency-no-promise-allsettled
Section titled “awaitly/concurrency-no-promise-allsettled”Severity: error
Replace Promise.allSettled with step.map() (which collects per-item Results without fail-fast).
error-* — boundary handling
Section titled “error-* — boundary handling”awaitly/error-check-unexpected-first
Section titled “awaitly/error-check-unexpected-first”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', },}// Badif (result.error._tag === 'TimeoutError') { /* */ }
// Goodif (isUnexpectedError(result.error)) { log.error('library bug', result.error.cause);} else if (result.error._tag === 'TimeoutError') { /* */}Custom configuration
Section titled “Custom configuration”To enable only specific rules:
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', }, },];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" }}Why these rules?
Section titled “Why these rules?”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 immediately — step() 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.