Skip to content

Initial TypeScript setup and conversion (CJS to ESM, ES5 class functions to ES6 classes)#705

Draft
ericyhwang wants to merge 12 commits intotypescriptfrom
typescript-initial
Draft

Initial TypeScript setup and conversion (CJS to ESM, ES5 class functions to ES6 classes)#705
ericyhwang wants to merge 12 commits intotypescriptfrom
typescript-initial

Conversation

@ericyhwang
Copy link
Copy Markdown
Contributor

@ericyhwang ericyhwang commented Apr 9, 2026

This PR covers TypeScript setup, file renames, then using codemods to convert CommonJS to ES modules and ES5 class functions to ES6 classes.

It targets the typescript branch, which we can use to stage the TypeScript changes until they're ready to merge into main. Making smaller intermediate PRs into that branch will make things easier to review.

For now, my goal with the typescript staging branch is to end up with TS source code that compiles into JS output compatible with the current JS source, so we could publish it a minor version. In a future major version, we can choose to update the compile target to drop ES5 support, especially if we want to start using things like async/await without the extra verboseness of downleveling.

We should probably major version soon anyways, since Node 18's been EoL for a year, and Node 20's about to reach EoL. The newer testing libraries actually don't run on Node 18, so I'm dropping that version from our CI in this PR.

Reviewer tips and notes:

  • I recommend going through the commits one by one.
  • Unfortunately, GitHub's new diff viewer doesn't respect the "Ignore whitespace" option when you have it show a file with a large diff, so for the codemod commit, it'd be better to pull this typescript-initial branch down and locally view the commit's diff, e.g. in your editor.
  • If you would like to compare the original JS source with the tsc-produced JS output, I have that pushed up to the typescript-cjs-class-codemods-js-outputs branch, in commit 640885e. Similarly, it's better to pull the branch and compare locally.

Let me know if you want to schedule time to go through this on a call!

Changes

  1. Update to latest dev dependencies (eslint, mocha, chai, nyc, sinon)
    • ESLint now requires the newer "flat config". I used the eslint migration tool to produce eslint.config.js based on the existing .eslintrc.js.
    • sinon@19 started stubbing nextTick by default, which causes some tests using fake timers to fail. To fix them, I used the new option clock.setTickMode({mode: 'nextAsync'});, which automatically progresses any immediate-style timers like nextTick.
  2. Set up TypeScript, rename all lib/**/*.js files to src/**/*.ts with no changes
    • Doing a commit with simple moves/renames guarantees that Git can detect the renames, making it easier to use git blame and other history spelunking tools on the "new" TS files.
  3. Pre-codemod manual changes
    • Move some module.exports statements up. Some class JSDoc comments were attached to the module.exports lines. Moving them so the comments are attached to the class functions means the codemod (next step) can do a better job porting the comments over to the ES6 class.
    • Minor tweaks to next-tick.ts and util.ts to eliminate duplicate declaration issues post-codemod.
  4. Run jscodeshift codemod-cjs-to-esm.ts and codemod-es5-classes.ts
    • Codemods run really fast, and a given codemod and input will consistently produce the same output, unlike LLM-based gen AI. That said, I did use gen AI to help write these codemods.
    • Background and details:
      • Many years ago, for Lever's TS conversions, I hand-wrote the codemods. LLMs were still in relative infancy and not as well-known back then.
      • Since I no longer have access to Lever code, I've made new ground-up codemods with gen-AI assistance, focused on converting ShareDB source code.
      • Specifically, I used Google's Antigravity IDE (their agent-focused editor) with a mix of Gemini 3.1 Pro and Gemini 3 Flash models. It did a pretty good job, though as expected I did have to go through many rounds of prompting and refining requirements to handle various cases.
      • At the end, I went through and re-read all the code in the codemods, doing light refactoring and adding comments as needed.
    • The codemods - they're gists for now until I get around to putting them in their own repo:

Next steps

First thing after this PR is to go through the many TS compilation errors and fix them. Most fall into these categories:

  • Declaring more class fields/methods, missing either due to assignments outside the constructor or due to inheritance from built-in classes not being able to use extends
  • Marking optional params
  • Typings for properties added onto object literals after initial creation

Then it'll be adding stronger types, likely based on our DefinitelyTyped definitions; doing more cleanup as needed; and converting test files to TS.

@ericyhwang ericyhwang requested a review from alecgibson April 9, 2026 21:59
- 18
- 20
- 22
- 24
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To try to keep this PR smaller, can we move all the dependency / environment bumps into a separate PR that we can merge first?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I can cherry-pick those. Do you have a preference for getting the dep updates into master, or just into the typescript staging branch?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's get it on master. I'm generally of the opinion that the more stuff we can get into master as early as possible, the less painful things will be later.

- name: Install
run: npm install
- name: Lint
if: github.ref_name != 'typescript' && github.base_ref != 'typescript' # Remove this condition after TS code lints cleanly
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, to try to keep this as close to master as possible, can we just update our eslint config to ignore src/?

@ericyhwang
Copy link
Copy Markdown
Contributor Author

Also, I did see that in the sharedb-mongo tests, the Node process is left hanging after all the tests pass.

I started looking into it with why-is-node-running yesterday, but didn't have time to get to the bottom of it before dinner. Current line of investigation is some interaction between newer sinon/fake-timers (which fakes more timer APIs) and the real Mongo driver.

Copy link
Copy Markdown
Collaborator

@alecgibson alecgibson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some notes on the "[codemod] Run jscodeshift codemod-cjs-to-esm.ts and codemod-es5-classes.ts" commit — please disregard if addressed later.

Note to self: I've looked at everything in this commit except agent.ts, whose no-whitespace diff wouldn't render in GH.

I'll try to continue reviewing on Monday.

Comment on lines +15 to +23
_docs;
_forwarders;
_emitters;

constructor() {
this._docs = Object.create(null);
this._forwarders = Object.create(null);
this._emitters = Object.create(null);
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we just inline these declaration statements and remove this unneeded constructor()?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That we'd want to do manually later, and not via codemod here.

Field initializers have more restrictions than constructor assignments, e.g. they can't rely on constructor params, always run prior to the main constructor body, etc.. In a codemod that's aiming for safety, trying to infer whether it's OK to convert an assignment to an initializer is more trouble than it's worth.

Comment on lines +15 to +17
_docs;
_forwarders;
_emitters;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess again not one for a patch bump, but we may want to consider making these private at some point in the future.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

private fields would break any consumers who may use the internals. While the underscore indicates that they really shouldn't use those fields, I admit that for tests, I've sometimes hooked into the internals of libraries. 😅

util.digAndRemove(this._emitters, doc.collection, doc.id);
util.digAndRemove(this._docs, doc.collection, doc.id);
};
import util = require('../../util');
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've honestly lost track in all of the CJS vs ESM nonsense — is import = require well behaved and interoperable? Any reason we're not using import ... from everywhere? I assume the latter requires type definitions(?)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://www.typescriptlang.org/docs/handbook/2/modules.html#es-module-syntax-with-commonjs-behavior

  • The TS-specific syntax import __ = require(__) directly compiles to a CJS require().
  • For the ESM import syntax import __ from __, when TS is outputting as CJS, it includes a shim in the each JS output file to enable importing modules both an ESM default export someModule.default as well as a CJS "whole-module" export someModule.

Here's a TS Playground snippet showing the difference:
https://www.typescriptlang.org/play/?target=1&module=1#code/JYWwDg9gTgLgBGAhjAFgUQM4gCIFMBmiArgDYwCS408+UEIcA5EqowNwBQoksCyKAYQBWGAEq4AjkWBRccALxxZUmbgAUzfowCUnDi3RY8hUhSqwAdEIjAAdmt37+wsZOmyrN+46A

Since I wanted to keep the JS output as close to the CJS original as possible for now, I opted for the direct analogue import __ = require.

If we want to output ESM instead of CJS in the future, then we'd have to switch to import __ from __ syntax, but that's easy enough to do with another codemod.

Comment on lines +154 to +163
RemotePresence.prototype._handleCreateDel = function() {
this._cacheOp(null);
this._setPendingPresence();
};

RemotePresence.prototype._handleLoad = function() {
this.value = null;
this._pending = null;
this._opCache = null;
this.presence._updateRemotePresence(this);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This got skipped by the converter, presumably because I typoed this in the original code 🙈

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heh, yeah, it's sticking these onto the base class. We can either fix it afterwards, or fix it in the JS now in a separate PR and I'll re-run the codemods.

Comment on lines +111 to +112
DB.prototype.projectsSnapshots = false;
DB.prototype.disableSubscribe = false;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do these need converting into instance properties?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naively switching to an instance property with field initializer would technically be a breaking change, since it's possible some user code is referencing DB.prototype.projectsSnapshots, to read or change the default value.

Prototype property related breakages were an issue we ran into with Derby-related conversions, so I'd updated our codemods back then to be more conservative with prototype properties. For these, I started with the safer approach from the get-go.

We will want to declare them as fields so the type system knows about them. I was going to do that manually later, but maybe the codemod can insert a field declaration...

export = Backend;
emitter.mixin(Backend);

Backend.prototype.MIDDLEWARE_ACTIONS = {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be an instance property?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above comment about how changing from prototype property to instance property is technically a breaking change.

submit: 'submit'
};

Backend.prototype.SNAPSHOT_TYPES = {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instance property?

Comment on lines +84 to +86
ShareDBError.prototype = Object.create(Error.prototype);
ShareDBError.prototype.constructor = ShareDBError;
ShareDBError.prototype.name = 'ShareDBError';
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't feel right

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unchanged from the original JS code:

sharedb/lib/error.js

Lines 11 to 13 in b6deed8

ShareDBError.prototype = Object.create(Error.prototype);
ShareDBError.prototype.constructor = ShareDBError;
ShareDBError.prototype.name = 'ShareDBError';

It's just relocated out of the class body.

I'm guessing it's so that you can do a check like if (errorInstance.name === 'ShareDBError')

@ericyhwang ericyhwang marked this pull request as draft April 11, 2026 00:59
@ericyhwang
Copy link
Copy Markdown
Contributor Author

I've moved the dev dependency upgrades to #706 and fixed the issue with the hanging Mocha process.

I might create a new PR for the TS changes after the next round of feedback, instead of dealing with rebasing, since the codemods make it easy to re-construct the changes. TBD, I'll decide next week.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants