Skip to content

Add basic CommonJS support #748

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 32 commits into from
Apr 25, 2025

Conversation

sandersn
Copy link
Member

@sandersn sandersn commented Apr 3, 2025

This PR adds basic CommonJS imports and exports. It's aiming to cover 80% of usage--a subset of TS semantics in most cases.

CommonJS imports are implemented basically the same way as in Strada. The binder and checker treat a variable declaration that matches a syntactic pattern as if it were the same as import x = require('x') in Typescript.

  • const x = require('x') is supported.
  • const { a, b, c } = require('x') is also supported (this is different than TS, but works the same as in Strada).
  • const a = require('x').a is no longer supported.

CommonJS export assignments are implemented as a synthetic variant of Typescript export assignments. When the parser sees module.exports = ..., it emits a synthetic JS export = ....

  • module.exports = x is supported, with the same semantics as export = x in TS. (This means that literal types don't widen because export= isn't a mutable location.)

CommonJS exports are implemented as a separate synthetic declaration. I don't create synthetic variable declarations because export var a = 1 both exports a and declares a local a. module.exports.a = 1 only does the former. More on that in Open Questions.

  • module.exports.a = 1 exports a, and allows references to module.exports.a in that file.

Other changes:

  • I deleted some code that is no longer needed because of (1) simpler const x = require support (2) synthetic export support. But I haven't completely cleaned house, especially in the binder.
  • I added JS support to the incomplete module resolution in fileloader/resolveImportsAndModuleAugmentations, which makes test results a lot better, even though it's still not how the finished port will work.
  • I moved the code to reparser.go, and will move the JSDoc tag code there later.

Open questions

  • Should we support local declaration of module.exports.x=? It is possible to hack the checker and name resolution to make it possible to refer to module.exports.x inside the CommonJS file, but it won't be pretty. I'm leaning toward not supporting this, at least until I can run top400 JS on Corsa after this PR. Based on the top100 JS corpus I looked at earlier, it's rare to reference an exported object within the exporting file.

  • A secondary question is: should module, exports and require error without a declaration, suggesting that you install @types/node? Strada never does this for JS, but in Corsa I think it makes sense to ask people with checkJs on to install @types/node if they're using CommonJS. This does introduce lots of errors into the baselines, but remember that plain JS files report only syntax errors.

  • How much of the ES/CJS interop failures in modulePreserve2 and 4, and importNonExportedMember12 are due to mistakes/omissions in this PR and how much are due to unfinished module exports code? I need somebody who understands ES/CJS interop well to look at these baselines.

  • Naming: The synthetic node for module.exports= is KindJSExportAssignment, based on export='s KindExportAssignment. For module.exports.x=, I chose KindCommonJSExport, but maybe it should be KindCommonJSExportAssignment or KindCommonJSExportVariableDeclaration. Opinions?

  • Should baselines skip all reparsed nodes? Just non-duplicate reparsed nodes? Skipping all of them makes the Strada baseline diffs smaller but omits, especially for @typedef, which was not baselined before.

Future work

  • Move other reparsing code into reparser.go
  • module.exports['illegal name']= -- requires generating a temp name to print it in a .d.ts file.
  • The combination
/** @typedef {number} ImplicitlyExported */
module.exports = { a, b, c }

Requires exporting ImplicitlyExported with the rest of the exports a,b,c. The most straightforward way is to synthesise a temp name for {a,b,c} and a namespace with the same name that merges with it. The synthetic namespace contains the implicitly exported type.

sandersn added 13 commits March 26, 2025 08:41
It builds, and I think I found all the places that need to change to
handle the JS versions. But I haven't hooked it up yet to find out.
The biggest tweak is very simple allowjs support in fileloader.go.
Without this this PR isn't really testable.
I believe there is still an *error* but the semantics now are as if it
were exported at the top level, just like Strada.
@sandersn sandersn changed the title Add basic commonjs support Add basic CommonJS support Apr 8, 2025
@sandersn sandersn marked this pull request as ready for review April 9, 2025 16:26
@Copilot Copilot AI review requested due to automatic review settings April 9, 2025 16:26
Copy link
Contributor

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR introduces basic CommonJS support by adding synthetic handling for CommonJS import/export patterns, including support for both module.exports assignments and property exports. Key changes include:

  • Extending various transformers, checkers, and binders to recognize and properly process the new KindJSExportAssignment and KindCommonJSExport node kinds.
  • Updating the parser, printer, and AST utilities to emit and consume synthetic nodes for CommonJS exports.
  • Enhancing module resolution and baseline test handling to accommodate JS files and corresponding CommonJS semantics.

Reviewed Changes

Copilot reviewed 740 out of 740 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
internal/transformers/* Modified switch cases to include KindJSExportAssignment in various module transformation paths.
internal/printer/printer.go Added new emitter functions for JSDoc types and CommonJS export declarations, and updated export assignment handling.
internal/parser/* Introduced logic in the parser to create synthetic nodes for CommonJS exports and update module indicators.
internal/compiler/fileloader.go Extended file resolution to correctly consider JS extensions when AllowJs is enabled.
internal/checker/* Updated grammar and export assignment handling to incorporate the new JSExportAssignment kind.
internal/binder/* Adjusted symbol declaration/binding logic to properly accommodate CommonJS export synthetic nodes.
internal/ast/* Added new Kind values and updated AST utilities and factories to support JSExportAssignment and CommonJSExport.
Comments suppressed due to low confidence (1)

internal/ast/kind.go:382

  • [nitpick] The naming of synthetic node kinds for CommonJS exports (i.e. 'KindJSExportAssignment' vs. 'KindCommonJSExport') appears inconsistent with the open questions in the PR. Consider unifying these names for clarity and consistency across the codebase.
KindJSExportAssignment

p.writeSpace()
p.writeKeyword("var")
p.writeSpace()
if node.Name().Kind == ast.KindStringLiteral {
Copy link
Preview

Copilot AI Apr 9, 2025

Choose a reason for hiding this comment

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

The TODO comment in the emitCommonJSExport function notes that illegal names are not handled. Consider implementing a proper mechanism (or deferring to a follow-up issue) to generate a valid temporary name for illegal export identifiers.

Copilot uses AI. Check for mistakes.

Copy link
Member Author

Choose a reason for hiding this comment

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

@rbuckton I'm not sure if I need a printer for commonjs exports, or any other reparsed node. If so, I'll open a bug to remind myself to emit a temp rename, or some other correct way to emit exports with arbitrary names.

@jakebailey
Copy link
Member

const a = require('x').a is no longer supported.

This surprises me; how are we choosing to drop this?

A secondary question is: should module, exports and require error without a declaration, suggesting that you install @types/node?

I would not do this. It is not uncommon to depend on these working in CJS code without the node types.

How much of the ES/CJS interop failures in modulePreserve2 and 4, and importNonExportedMember12 are due to mistakes/omissions in this PR and how much are due to unfinished module exports code? I need somebody who understands ES/CJS interop well to look at these baselines.

I haven't looked either, but we're missing most of the code that makes externalModuleIndicator work, so a lot of this doesn't behave as expected.

Should baselines skip all reparsed nodes? Just non-duplicate reparsed nodes? Skipping all of them makes the Strada baseline diffs smaller

I would definitely be eliminating diffs.

Copy link
Member Author

@sandersn sandersn left a comment

Choose a reason for hiding this comment

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

Some commentary from the last pass I made over the diff.

@@ -1392,22 +1393,6 @@ func IsFunctionPropertyAssignment(node *Node) bool {
return false
}
Copy link
Member Author

Choose a reason for hiding this comment

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

we have a private and public duplicate of this function so I deleted the private one

@@ -1473,10 +1458,6 @@ func IsEffectiveExternalModule(node *SourceFile, compilerOptions *core.CompilerO
return IsExternalModule(node) || (isCommonJSContainingModuleKind(compilerOptions.GetEmitModuleKind()) && node.CommonJSModuleIndicator != nil)
}
Copy link
Member Author

Choose a reason for hiding this comment

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

this is unused

@@ -1646,20 +1627,7 @@ func IsEnumConst(node *Node) bool {
}

func ExportAssignmentIsAlias(node *Node) bool {
Copy link
Member Author

Choose a reason for hiding this comment

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

I believe this was for syntactic module.exports= support and will never be used.

@@ -378,12 +380,16 @@ func GetSymbolNameForPrivateIdentifier(containingClassSymbol *ast.Symbol, descri
}

func (b *Binder) declareModuleMember(node *ast.Node, symbolFlags ast.SymbolFlags, symbolExcludes ast.SymbolFlags) *ast.Symbol {
container := b.container
Copy link
Member Author

Choose a reason for hiding this comment

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

commonjs exports are file exports, even if they're nested inside something else (though Corsa, not Strada, gives a diagnostic too)

if !ast.IsAmbientModule(node) && (hasExportModifier || b.container.Flags&ast.NodeFlagsExportContext != 0) {
if !ast.IsLocalsContainer(b.container) || (ast.HasSyntacticModifier(node, ast.ModifierFlagsDefault) && b.getDeclarationName(node) == ast.InternalSymbolNameMissing) {
return b.declareSymbol(ast.GetExports(b.container.Symbol()), b.container.Symbol(), node, symbolFlags, symbolExcludes)
if !ast.IsAmbientModule(node) && (hasExportModifier || container.Flags&ast.NodeFlagsExportContext != 0) {
Copy link
Member Author

Choose a reason for hiding this comment

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

commonjs exports need to bind module.exports.x locally not just x, so I skipped it for now. See Open Questions

@@ -922,6 +936,19 @@ func (b *Binder) bindFunctionExpression(node *ast.Node) {
b.bindAnonymousDeclaration(node, ast.SymbolFlagsFunction, bindingName)
}

Copy link
Member Author

Choose a reason for hiding this comment

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

this is a port, with some advanced module code not done

@@ -12,6 +12,7 @@ type NameResolver struct {
Error func(location *ast.Node, message *diagnostics.Message, args ...any) *ast.Diagnostic
Globals ast.SymbolTable
ArgumentsSymbol *ast.Symbol
RequireSymbol *ast.Symbol
Copy link
Member Author

Choose a reason for hiding this comment

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

this allows error-free use of require even without @types/node installed. This is here as part of the port of const x = require code from Strada. I'm torn about including it; module.exports= does error if you don't have node types installed, and I think that's better. Thoughts? I'll make a note to remove it for now.

@@ -13547,15 +13553,6 @@ func (c *Checker) resolveSymbolEx(symbol *ast.Symbol, dontResolveAlias bool) *as

func (c *Checker) getTargetOfImportEqualsDeclaration(node *ast.Node, dontResolveAlias bool) *ast.Symbol {
// Node is ImportEqualsDeclaration | VariableDeclaration
Copy link
Member Author

Choose a reason for hiding this comment

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

const a = require('x').a is no longer supported

@@ -16980,16 +17023,13 @@ func (c *Checker) getTypeOfAlias(symbol *ast.Symbol) *Type {
}
targetSymbol := c.resolveAlias(symbol)
exportSymbol := c.getTargetOfAliasDeclaration(c.getDeclarationOfAliasSymbol(symbol), true /*dontRecursivelyResolve*/)
declaredType := c.getExportAssignmentType(exportSymbol)
// It only makes sense to get the type of a value symbol. If the result of resolving
// the alias is not a value, then it has no type. To get the type associated with a
// type symbol, call getDeclaredTypeOfSymbol.
// This check is important because without it, a call to getTypeOfSymbol could end
// up recursively calling getTypeOfAlias, causing a stack overflow.
if links.resolvedType == nil {
Copy link
Member Author

Choose a reason for hiding this comment

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

this was only needed to support old syntactic module.exports=

/// module.exports.name = expr
jsDeclarationKindExportsProperty
/// className.prototype.name = expr
jsDeclarationKindPrototypeProperty
Copy link
Member Author

Choose a reason for hiding this comment

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

I included the last 3 entries for future use, although I'm not 100% sure that they will be needed, or stay private to this module.

+
+ module.exports = /** @type {FooFun} */(void 0);
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+!!! error TS2309: An export assignment cannot be used in a module with other exported elements.
Copy link
Member

Choose a reason for hiding this comment

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

This seems like a bug?

Copy link
Member Author

Choose a reason for hiding this comment

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

typedefs are implicitly exported, and need to not be in the presence of module.exports=. I'm going to fix this separately because it's going to be tricky.

Copy link
Member Author

Choose a reason for hiding this comment

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

Thinking about this more, I'll probably need to bring back delayedBindJSDocTypedef from Strada. Unfortunate, but it will still be simpler than Strada's, I promise.

import test from "./test";
+ ~~~~~~~~
+!!! error TS2306: File 'test.js' is not a module.
export type test
-
Copy link
Member

Choose a reason for hiding this comment

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

Do we have a parser bug?

Copy link
Member Author

Choose a reason for hiding this comment

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

I suspect it's a difference in error reporting ("Type expected" and "'=' expected" appear to be on the same location), but I'll confirm.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yep. The code is working as expected in Corsa but not in Strada. The two messages are both on the EOF token in Corsa. I'm not sure why they evade position-based deduping in Strada.

+==== app.js (2 errors) ====
+ function require(a) {
+ ~~~~~~~
+!!! error TS2441: Duplicate identifier 'require'. Compiler reserves name 'require' in top level scope of a module.
Copy link
Member

Choose a reason for hiding this comment

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

Hm, why is this happening? Maybe a module mode related problem?

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not sure why the dupe is happening, but the checker is supposed to avoid resolving require to the global one if there is a local one available, so I need to stop that from happening.

In other words, the const fs = require('fs') below is not supposed to be an import, just a call to the local function.

Comment on lines +7 to +8
+=== /jsx.jsx ===
+
Copy link
Member

Choose a reason for hiding this comment

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

Odd baseline, maybe a bug in the printer?

@sandersn
Copy link
Member Author

Some replies:

const a = require('x').a is no longer supported.

This surprises me; how are we choosing to drop this?

The only examples I found in top200 JS were webpack test cases.

A secondary question is: should module, exports and require error without a declaration, suggesting that you install @types/node?

I would not do this. It is not uncommon to depend on these working in CJS code without the node types.

Even if you have checkJs: true? But you're right, it is pretty annoying just in the baseline diffs.

Should baselines skip all reparsed nodes? Just non-duplicate reparsed nodes? Skipping all of them makes the Strada baseline diffs smaller

I would definitely be eliminating diffs.

I moved this up to the type tag PR and will consider re-adding non-duplicate reparsed nodes after Corsa is done and we don't care about baseline diffs anymore.

@andrewbranch
Copy link
Member

Haven’t looked at the code yet, but from the description, I will note that

exports.a = "a";

is more common than

module.exports.a = "a";

in my experience. (Our own CommonJS emit, for example, does this.) (The way to reason about this, if it helps: the module system will use the value of module.exports as the source of truth, but both module and exports are free variables in the CJS script scope, with module.exports initialized to exports and exports initialized to an empty object. So by default, when you mutate the properties of exports, you mutate the properties of module.exports. But if you assign exports = ..., that rebinds the free variable and module.exports is unaffected. Similarly, if you reassign to module.exports = {}, subsequent mutations of exports.a will be ineffective.)

Should we support local declaration of module.exports.x=? It is possible to hack the checker and name resolution to make it possible to refer to module.exports.x inside the CommonJS file, but it won't be pretty. I'm leaning toward not supporting this, at least until I can run top400 JS on Corsa after this PR. Based on the top100 JS corpus I looked at earlier, it's rare to reference an exported object within the exporting file.

When searching for this, make sure you're looking for the exports.x form too. I think this is fairly common.

A secondary question is: should module, exports and require error without a declaration, suggesting that you install @types/node? Strada never does this for JS, but in Corsa I think it makes sense to ask people with checkJs on to install @types/node if they're using CommonJS. This does introduce lots of errors into the baselines, but remember that plain JS files report only syntax errors.

I would not do this—it seems like we’re making CommonJS much less powerful, so I’m not sure it makes sense to make it more nitpicky at the same time.

I think the broad questions about what can be dropped depends on the high level goal/philosophy here. If I were writing new CommonJS code (??), I would be happy with the level of support you have here. But existing code does all kinds of weird stuff, and I don’t think you’re going to find much of it in the topNNN suite. If we had a bottom10%, though... Are those users actively maintaining their code and upgrading TS versions? I doubt it.

@sandersn
Copy link
Member Author

sandersn commented Apr 10, 2025

I will note that exports.a = "a"; is more common than module.exports.a = "a";in my experience.

Thanks, my intuitions about that are out-of-date.

I think the broad questions about what can be dropped depends on the high level goal/philosophy here.

The customers I'm trying to support are: people writing TS-in-JSDoc, people writing miscellaneous unchecked JS, and people browsing emitted JS in node_modules. The only code base in topNNN that I've seen with commonjs is webpack, and it uses only a single form (module.exports= + @typedef). CommonJS is basically for understanding JS in node_modules.

@andrewbranch
Copy link
Member

people browsing emitted JS in node_modules

This contingency will likely be harmed by the removal of any require/exports-related analysis, unfortunately.

This removes support for `module.exports=` and `module.exports.x=` in
the same file. As part of this, it also removes support for implicitly exported
typedefs, which I plan to add back via support in the binder.
Plus several other bug fixes.

I still need to look over the baselines to figure out remaining bugs and
limitations.
@sandersn
Copy link
Member Author

The latest commit resolves module.exports and exports more or less the same way that Strada did -- by treating them as the source file's symbol.

I still need to look through the baselines for other bugs and limitations, and fix up the errors that you all have already pointed out.

+>module.exports : any
+>module : any
+>exports : any
+>module.exports : (foo: typeof Foo) => string
Copy link
Member Author

Choose a reason for hiding this comment

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

typeof Foo is correct -- the typeof-insertion insertion shenanigans in Strada were doing the wrong thing.

@@ -45,9 +35,5 @@
+!!! error TS1110: Type expected.
+==== types3.ts (1 errors) ====
Copy link
Member Author

Choose a reason for hiding this comment

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

weird difference in spacing between Strada and Corsa baselines.

{
// 'exports' does not provide a contextual type to a function-class
exports.Cls = function() {
->exports.Cls = function() { this.x = 0; } : typeof Cls
->exports.Cls : typeof Cls
->exports : typeof import("/a")
->Cls : typeof Cls
->function() { this.x = 0; } : typeof Cls
+>exports.Cls = function() { this.x = 0; } : () => void
+>exports.Cls : any
Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not sure why exports.Cls : any but it's not a problem with exports resolution, because exports.x works above. I suspect it's something wrong with partially implemented construction function typing.

@jakebailey
Copy link
Member

Is there anything left in this PR? It's hard to view and the thread is long so I'm not sure where we are at; I tried to skim the code until the tab crashed, and I'm not sure there's anything that jumps out at me, especially if this is a net improvement over the current state that we can update later.

@sandersn
Copy link
Member Author

@jakebailey You're probably right, I've just been fixing bugs that would be easier to review individually.

Still,

  1. I want to look over the baselines one more time.
  2. I have many many unused baselines I need to remove.
  3. I'll ping you directly when I'm done with that.

@sandersn sandersn enabled auto-merge April 25, 2025 15:36
Copy link
Member

@jakebailey jakebailey left a comment

Choose a reason for hiding this comment

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

LGTM just to get this going.

@sandersn sandersn added this pull request to the merge queue Apr 25, 2025
Merged via the queue into microsoft:main with commit 6d2aaa1 Apr 25, 2025
23 checks passed
@sandersn sandersn deleted the add-basic-commonjs-support branch April 25, 2025 16:17
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.

3 participants