Report declaration spaces on ExportNode - #226
Draft
Janpot wants to merge 6 commits into
Draft
Conversation
The resolved type does not identify the declaration form: export type X = 'x' and export const x: X = 'x' both resolve to the same literal type. Consumers that need to tell runtime values apart from type declarations had no reliable signal, and type-shape heuristics fail on annotated constants. Add isValue, isType, and isNamespace to ExportNode, reporting which of TypeScript's declaration spaces the export occupies (SymbolFlags.Value / Type / Module, alias-resolved). The three spaces are a closed classification, so the flags stay stable as new syntax forms are parsed, and merged declarations (class, enum, interface + const) are represented honestly by setting several flags at once.
This was referenced Aug 25, 2026
- Type-only exports (export type { X }, export type * from) no longer report
isValue: the runtime value does not cross a type-only export site. Specifiers
carry an isTypeOnlyExport descriptor flag; the type-only star-export path in
moduleParser masks the surviving pure types (enums occupy the value space at
their declaration).
- An alias merged with a local declaration (import { fn } + interface fn)
keeps both symbols' spaces instead of only the target's.
- isNamespace is now based on an actual namespace/module declaration, not
SymbolFlags.Module, which the binder also sets for expando assignments
(fn.extra = ...).
- ExportNode takes one DeclarationSpaces object instead of three adjacent
boolean parameters.
- Docs: soften 'exists at runtime' (const enums are inlined by default) and
note the unresolvable-re-export fallback; the namespace-only fixture case now
actually emits a namespace-only node (via a type-only specifier, since a
directly exported namespace is flattened into its members).
- Use ts.isTypeOnlyExportDeclaration instead of hand-rolling its ExportSpecifier branch, and drop the dead '|| undefined' normalization. - Thread the type-only star-export bit through parseExport so the descriptor masking site in createExportNodesFromDescriptor is the single place the type-only rule is applied; no post-construction mutation remains, so the ExportNode space flags are now readonly. - withType passes 'this' as the DeclarationSpaces argument instead of re-bundling the three fields. - Avoid the merged declarations array in getDeclarationSpaces; trim its JSDoc to the implementation-specific facts. - Collapse repeated 'undefined, valueSpaces' constructor tails in exportTransforms.test.ts behind a createValueExport helper.
Two escapes from the type-only masking, found in review:
- The namespace member descriptors of a merged declaration were not stamped
with isTypeOnlyExport, so 'export type { nsFn }' masked nsFn but left
nsFn.inner claiming a runtime value.
- Type-only-ness was read off the export declaration only, so a value
re-exported through 'import type' ('import type { fn }' + 'export { fn }')
reported isValue even though tsc emits no runtime binding.
Both are fixed by isTypeOnlyAliasChain, which walks the alias chain hop by
hop (getImmediateAliasedSymbol) checking each declaration with
ts.isTypeOnlyImportOrExportDeclaration - resolving the chain in one jump
cannot see a type-only site in the middle - and stamps the result on every
descriptor of the export, namespace members included.
Also from review: DeclarationSpaces fields are readonly (the masking site
spreads instead of mutating), ExportNode declares 'implements
DeclarationSpaces' so withType passing 'this' is load-bearing by contract,
and the fixture now covers the directly exported function+namespace merge,
'export default class', and both regressions.
`export type * as N from '...'` is the third type-only re-export form, and it went through neither masking site: the namespace resolver never asked whether the alias chain was type-only, and the star-export scan skips anything with an export clause. Members came back byte-identical to a plain `export * as`, so a value with no runtime binding reported `isValue: true`. Attributing type-only-ness to the declaring file also masked symbols that a plain `export *` genuinely does export, when both re-exports reach the same module. Resolve both sets of specifiers to source files and let the value path win. Read the alias chain before the unresolved-target return as well, so members of a merged namespace are masked even when the target cannot be resolved.
The mask was spelled out in both the namespace and specifier resolvers. Neither is the place it belongs: whichever declaration form carried a symbol, the question is the same, so ask it once where the dispatch returns. That also drops the descriptor field the mask immediately overwrote. Let the star-export scan return the source files it means, rather than two sets of specifiers for the caller to resolve and subtract. Collecting the specifiers before resolving any of them means a file with no `export type *` resolves nothing, which is most of them. Say what the subtraction actually covers: star exports written in this file. A module reached through a further module's star export is not modelled, and the comment read as though it were.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Consumers can't tell a runtime constant from a type declaration:
export type X = 'x'andexport const x: X = 'x'resolve to the same literal node. James ran into this in his review of mui/mui-public#1713 (mui/mui-public#1713 (comment)), where the docs pipeline can silently invent or drop documented constants because of it.This adds
isValue,isType, andisNamespacetoExportNode— the declaration spaces of the export's symbol, alias-resolved. The spaces are a closed set, so the flags stay stable as the parser learns new syntax, and merged declarations (class, enum,interface X+const X, an import merged with a local interface) simply set several flags. Type-only re-exports (export type { X },export type * from) never occupy the value space, andisNamespacerequires an actual namespace declaration — expando assignments (fn.extra = ...) don't count.