0161 - goccia:ast reports type-level declarations by kind, with no parse switch
Date: 2026-10-26
Area: runtime, parser
Related: ADR 0117, ADR 0118, Type Annotations, #1525, #1526, #1549
Context
Types are comments at run time, so the parser skips each type-level
declaration (type, interface, import type, export type,
export interface) and leaves a TGocciaEmptyStatement covering its text.
ADR 0117 named statement kinds as the
thing a rule dispatches on, and goccia:ast reported all of these as
EmptyStatement. A rule could not tell a type declaration from a stray ;.
An import-rewriting codemod that walked ImportDeclaration nodes skipped 58
import type lines in a 123-file tree.
ADR 0117 also said of type annotations that there was nothing to expose,
because they are gone before the tree exists. The open question was whether
keeping the distinction needs a parse switch that only goccia:ast turns on,
so that running a program is not affected.
Decision
Each type-level declaration is reported as what it was written as, over its
whole text. type and interface declarations get their own kinds:
TypeAliasDeclaration, InterfaceDeclaration, ExportTypeAliasDeclaration
and ExportInterfaceDeclaration. The module forms keep the kind of the
statement they look like: import type is an ImportDeclaration,
export type { A } an ExportDeclaration, and export type { A } from or
export type * from a ReExportDeclaration. A typeOnly property tells them
apart. It is true on every type-level node and false on a runtime import or
export, and no other kind carries it. EmptyStatement is left for a lone ;.
Keeping the module kinds follows the failure that was reported. A tool walking
imports must find import type lines without first learning a second kind.
The TypeScript compiler API, the reference ADR 0117 was designed against, makes
the same choice: isTypeOnly is a flag on the import clause and on the export
declaration, and the declarations have kinds of their own.
The parser records what it erased, and nothing else changes. The empty
statement the parser already creates now holds an ErasedTypeKind (etkNone
for a real ;), set at the five places that skip a type-level declaration. The
skipped tokens are still skipped, and no type text, name or member is kept. The
only extra work is reading the token after export type before the skip, and
afterwards checking the tokens already consumed for a from after the
specifier list.
There is no switch. The bytecode compiler, the only thing that runs a parsed program, receives the same node it did before: the same class, the same span and no children. It never reads the new field. The cost is one enum field on each empty statement, which is small next to the span and label list every statement already has.
The tag is a field and not a subclass, because the compiler tests the exact
class in places: Goccia.Compiler.OperandSafety and
Goccia.Compiler.NumericBindings compare ClassType with
TGocciaEmptyStatement, and a subclass would fall out of those branches.
Rejected
- A parse-only retention mode. A switch is worth having only when parsing for a tool keeps something that running must not pay for. Here it would keep nothing. It belongs with type content: names, members or annotation ranges. Keeping those allocates and changes what the parser builds, which is the per-call parse options of #1526 and the expression-level tree of #1549. This decision does not constrain either one, because the tag is set unconditionally.
- Classifying in the flattener from the leading keyword. That repeats a
decision the parser has already made, and the workaround the codemod used
did exactly this. Telling
export type { A }fromexport type { A } fromalso needs the tokens, which only the parser has. - Distinct kinds for the module forms (
ImportTypeDeclarationand so on). These keep the node shape at five properties, but every import walker would have to know a second kind, and missing it is the bug that was reported.
Consequences
typeOnlyextends the node shape ADR 0117 fixed at{ kind, start, end, loc, children }. It is present on seven kinds only, so the properties a node has still depend on nothing but its kind.import type from "./x"andimport type, { y } from "./x"are value imports, the first binding namedtype, but the parser erases both today, so neithertypenoryis bound. They are therefore reported as type-only imports. The report matches what the engine runs; the erasure itself is wrong.declareforms (#1527) andexport default interfaceare still syntax errors and have no kind.examples/_experimental/ast-ranges.mjsre-parses the exported type kinds at the top level, as it does the other module kinds. Overwebsite/src, run with the nine--compat-*flags its code needs, every node round-trips except theProgramnodes, and the one file that does not parse fails ondeclare global.