Skip to content

Change export = structurally to allow other exported elements in the same file #62103

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

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from

Conversation

weswigham
Copy link
Member

To allow a export = to be typechecked alongside other exports in the module without the .cjsExportMerged layering we had going on. I've also deleted the "export = can't be in the same module as other exports" error, as, well, that was the point, but if we wanted to, we could always keep that error in ts source files... but, IMO, a more limited "value exports should not be lexically before a value export =" is actually the only error you need to prevent runtime problems with the usual cjs/bundler emit, and would be the better error.

This, in turn, should allow a cjs module.exports = to behave identically to a TS exports = (a goal for corsa), as now the later has all the functionality of the former.

I still need to go through the fourslash diffs and see what LS operations need to handle the export= symbol handling logic getting pushed up a layer (some quickfixes, for sure), but baseline-wise, these actually mostly look really good - this change makes it so modules always have their own symbol (and are directly never forwarded to another symbol - they just behave as though they are at times), and so can always be referred to by that symbol.

@weswigham weswigham requested a review from sandersn July 22, 2025 01:21
@typescript-bot typescript-bot added Author: Team For Uncommitted Bug PR for untriaged, rejected, closed or missing bug labels Jul 22, 2025
@@ -2668,6 +2668,9 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
* If target is not transient, mergeSymbol will produce a transient clone, mutate that and return it.
*/
function mergeSymbol(target: Symbol, source: Symbol, unidirectional = false): Symbol {
if (target === source) {
return source; // target and source already merged (likely same symbol found in multiple export tables)
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 change is only needed because I reused mergeSymbolTables to build the combined symbol table in resolveStructuredTypeMembers. If it wasn't to taste, I could always write a custom table merging function that handles this case outside of the core mergeSymbol.

@@ -2873,6 +2876,16 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
}
}
}
if (mainModule.exports?.get(InternalSymbolName.ExportEquals) && moduleAugmentation.symbol.exports?.size) {
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 almost a carbon copy of the export * logic above - the exception being that a local declaration doesn't implicitly "beat" something added by an export = - they have equal weight and get merged.

@@ -4533,6 +4551,9 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
symbol = getMergedSymbol(getSymbol(getExportsOfSymbol(resolveAlias(namespace)), right.escapedText, meaning));
}
if (!symbol) {
if (isExportAssignmentAny(namespace)) {
return unknownSymbol; // no error - lookup on psuedo-`any` module
Copy link
Member Author

Choose a reason for hiding this comment

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

Previously this was handled one level up the stack because the module itself was the any-typed const. Now it's not any (and can contain other well-typed members, like @typedefs), but it contains an export= that points at any, and we check that there to reenable the error-less lookup it used to have.

Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
return unknownSymbol; // no error - lookup on psuedo-`any` module
return unknownSymbol; // no error - lookup on pseudo-`any` module

}
getSymbolLinks(merged).cjsExportMerged = merged;
return links.cjsExportMerged = merged;
return resolveSymbol(moduleSymbol, dontResolveAlias)
Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, I could probably just delete resolveExternalModuleSymbol now - it's just an alias for resolveSymbol.

@@ -5137,6 +5126,7 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
function getExportsOfModule(moduleSymbol: Symbol): SymbolTable {
const links = getSymbolLinks(moduleSymbol);
if (!links.resolvedExports) {
links.resolvedExports = emptySymbols
Copy link
Member Author

Choose a reason for hiding this comment

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

getExportsOfModuleWorker can now recursively ask for it's own exports in cases like module.exports = exports - this short-circuits that, but still allows other things to merge into the cycle, despite the (sorta erroneous, sorta not) circular export.

extendExportSymbols(symbols, table);
}
}
symbols.delete(InternalSymbolName.ExportEquals)
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 maintains the "the resolved exports don't contains an export= symbol" expectation most callers have. It's naturally still accessible on the raw, unresolved symbol.exports table for callers that need it, though.

@@ -14588,6 +14592,48 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
}
type.constructSignatures = constructSignatures;
}

// And lastly, if the underlying symbol has an export=, we need to copy the type structure from it
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 logic and the getExportsOfModuleWorker should be identical, except the former is only concerned with copying values for the sake of symbolic traversal, while this copies up all type structure from the export= target - members, signatures, and index infos.

Comment on lines 34802 to 34803
const isPsuedoAnyModule = (parentSymbol && isExportAssignmentAny(resolveSymbol(parentSymbol)))
const isAnyLike = isTypeAny(apparentType) || apparentType === silentNeverType || isPsuedoAnyModule;
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
const isPsuedoAnyModule = (parentSymbol && isExportAssignmentAny(resolveSymbol(parentSymbol)))
const isAnyLike = isTypeAny(apparentType) || apparentType === silentNeverType || isPsuedoAnyModule;
const isPseudoAnyModule = (parentSymbol && isExportAssignmentAny(resolveSymbol(parentSymbol)))
const isAnyLike = isTypeAny(apparentType) || apparentType === silentNeverType || isPseudoAnyModule;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Author: Team For Uncommitted Bug PR for untriaged, rejected, closed or missing bug
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants