| name | modernize-code |
| description | Modernize legacy callback-based code to async/await TypeScript. Use when converting code that uses async.series, async.parallel, defaults(), nested callbacks, or when migrating CoffeeScript methods to modern TypeScript patterns. |
| allowed-tools | Read, Edit, Bash(pnpm test:*), Bash(pnpm build:*), Bash(grep:*), Bash(prettier -w:*) |
Modernize Code to Async/Await
This Skill guides you through converting legacy callback-based code into clean async/await TypeScript.
What this Skill does
Provides a complete modernization process:
- Verify test coverage exists and is adequate
- Remove async.series/parallel and replace with async/await
- Convert to async function returning Promise or Promise
- Wrap callbacks with callback_opts for backwards compatibility
- Replace defaults() with TypeScript destructuring
- Structure with try/catch for proper error handling
- Update callers to use direct async/await
- Update type definitions in types.ts
- Run tests to verify everything works
When to apply this Skill
- Methods using
async.series or async.parallel for sequencing operations
- Callback-based APIs with
opts.cb(err) or opts.cb(err, result) patterns
- Code using
defaults(opts, {...}) for option handling
- Nested callback chains that could be flattened with async/await
- Legacy CoffeeScript methods being migrated to TypeScript
Modernization Process
Step 0: Verify Test Coverage
Before touching any code:
- Review existing tests for the method
- Ensure tests cover happy path and main side effects
- Identify gaps in coverage (error handling, edge cases)
- If tests are missing, write them FIRST using TDD workflow
Example test structure:
import { db } from "@cocalc/database";
import getPool, { initEphemeralDatabase } from "@cocalc/database/pool";
describe("method tests", () => {
beforeAll(async () => {
await initEphemeralDatabase({});
}, 15000);
afterAll(async () => {
db()._close_test_query?.();
await getPool().end();
});
it("performs expected operation", async () => {
});
});
Step 1: Remove async.series/async.parallel
Replace async library patterns with native async/await:
Before:
return async.series(
[
(cb) => this.operation1({ ...opts, cb }),
(cb) => this.operation2({ ...opts, cb }),
(cb) => this.operation3({ ...opts, cb }),
],
(err) => opts.cb?.(err),
);
After:
try {
await callback_opts(this.operation1.bind(this))({ ...opts });
await callback_opts(this.operation2.bind(this))({ ...opts });
await callback_opts(this.operation3.bind(this))({ ...opts });
opts.cb?.();
} catch (err) {
opts.cb?.(err);
}
Step 2: Convert to Async Function
For methods with no return value:
blob_maintenance(opts: BlobMaintenanceOpts) {
}
async blob_maintenance(opts: BlobMaintenanceOpts): Promise<void> {
}
For methods with return values:
get_stats(opts: GetStatsOpts) {
}
async get_stats(opts?: GetStatsOpts): Promise<Stats> {
return stats;
}
IMPORTANT: Avoid async IIFE anti-pattern
When modernizing, convert the entire method to async rather than wrapping async code in an IIFE:
doSomething(r: Opts, cb: CB) {
if (someCondition) {
return;
}
(async () => {
try {
await someAsyncOperation();
cb();
} catch (err) {
cb(err);
}
})();
}
async doSomething(r: Opts, cb?: CB): Promise<void> {
try {
if (someCondition) {
cb?.();
return;
}
await someAsyncOperation();
cb?.();
} catch (err) {
cb?.(err);
}
}
Step 3: Use callback_opts
import { callback_opts } from "@cocalc/util/async-utils";
await callback_opts(this.syncstring_maintenance.bind(this))({
repeat_until_done: true,
limit: 500,
map_limit,
});
Key points:
- Always use
.bind(this) to preserve method context
- Remove
cb from the options object when calling via callback_opts
Step 4: Replace defaults() with TypeScript Destructuring
Before:
const optsWithDefaults = defaults(opts, {
path: "/backup/blobs",
map_limit: 1,
throttle: 0,
}) as BlobMaintenanceOpts;
After:
const {
path = "/backup/blobs",
map_limit = 1,
throttle = 0,
cb = undefined,
} = opts;
NOTE: Add jsDoc for the opts.[param] to keep the "comment" comments around
Step 5: Structure with try/catch
For Promise:
async blob_maintenance(opts: BlobMaintenanceOpts): Promise<void> {
const { path = "/backup/blobs", cb = undefined } = opts;
try {
await callback_opts(this.operation1.bind(this))({ path });
cb?.();
} catch (err) {
cb?.(err);
}
}
For Promise:
async get_stats(opts?: GetStatsOpts): Promise<Stats> {
const { cb = undefined } = opts ?? {};
try {
const stats = await this.computeStats();
cb?.(undefined, stats);
return stats;
} catch (err) {
cb?.(err);
throw err;
}
}
Step 6: Update Callers
Search for all places that call the method and update:
grep -r "blob_maintenance" packages/ --include="*.ts" --include="*.tsx"
Before:
await callback2(database.blob_maintenance);
After:
await database.blob_maintenance({});
Step 7: Update Type Definitions
Update the method's type signature in the appropriate interface file:
blob_maintenance(opts: BlobMaintenanceOpts): Promise<void>;
get_stats(opts?: GetStatsOpts): Promise<Stats>;
Step 8: Final Verification
pnpm build
pnpm test
pnpm tsc --noEmit
Common Pitfalls to Avoid
- ❌ Forgetting to call
cb() on success/error - Breaks backwards compatibility
- ❌ Not using
.bind(this) with callback_opts - Loses method context
- ❌ Using
defaults() instead of destructuring - Old pattern, less idiomatic
- ❌ Not updating type signatures - Causes type errors
- ❌ Skipping test verification - May introduce regressions
- ❌ Forgetting to return values - If callback had
cb(err, result), async function should return result
- ❌ Not re-throwing errors - Breaks error handling for async/await callers
- ❌ Using async IIFE
(async () => {...})() inside methods - Convert the entire method to async instead; IIFEs are a workaround that creates unnecessary nesting
Additional Resources
For comprehensive details and more examples, see:
- Complete guide: dev/MODERNIZE_CODE.md
- callback_opts utility:
@cocalc/util/async-utils
- Testing utilities:
@cocalc/database/pool for database initialization
- Example migrations: Browse
packages/database/postgres/ for real-world examples