Skip to content

Report declaration spaces on ExportNode - #226

Draft
Janpot wants to merge 6 commits into
michaldudak:mainfrom
Janpot:export-declaration-spaces
Draft

Report declaration spaces on ExportNode#226
Janpot wants to merge 6 commits into
michaldudak:mainfrom
Janpot:export-declaration-spaces

Conversation

@Janpot

@Janpot Janpot commented Aug 25, 2026

Copy link
Copy Markdown

Consumers can't tell a runtime constant from a type declaration: export type X = 'x' and export 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, and isNamespace to ExportNode — 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, and isNamespace requires an actual namespace declaration — expando assignments (fn.extra = ...) don't count.

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.
Janpot added 5 commits August 25, 2026 14:59
- 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.
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.

1 participant