Skip to content

fix(1374): support declaration emit for expando functions #1399

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 14 commits into
base: main
Choose a base branch
from

Conversation

a-tarasyuk
Copy link
Contributor

@a-tarasyuk a-tarasyuk commented Jul 13, 2025

Fixes #1374

@a-tarasyuk a-tarasyuk force-pushed the fix/1374 branch 11 times, most recently from 698d40d to c063ac4 Compare July 13, 2025 23:15
@a-tarasyuk a-tarasyuk requested a review from jakebailey July 14, 2025 11:09
@a-tarasyuk a-tarasyuk requested a review from jakebailey July 14, 2025 13:50
- var _a: boolean;
- export var normal: boolean;
+ var _b: boolean;
Copy link
Member

Choose a reason for hiding this comment

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

It's interesting that this temp variable turns into _b and not _a; it's not incorrect but I wonder if something isn't being cleared between expandos or something.

(I am reading this on my phone so have not actually read the code yet)

+export declare function foo(): void;
Copy link
Member

Choose a reason for hiding this comment

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

Maybe not for this PR but I wonder what the leftover code difference is that's adding "declare" to these.

Copy link
Member

@weswigham weswigham left a comment

Choose a reason for hiding this comment

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

This looks like a pretty straight port of the logic from strada - @sandersn should weigh in on weather he thinks that's right. I do not recall if these is one of the constructs that changed meaningfully in the js reparser rewrite and, thus, should also have it's declaration emit logic adjusted, too.

@a-tarasyuk a-tarasyuk closed this Jul 14, 2025
@sandersn
Copy link
Member

Expandos didn't fundamentally change, but they are losing a lot of their complicated features. So the core changes to emit might be right-ish, but you won't need to reintroduce any code in the parser or binder, I think.

@a-tarasyuk
Copy link
Contributor Author

@sandersn Thanks for the clarification.

@@ -3633,3 +3637,79 @@ func GetSemanticJsxChildren(children []*JsxChild) []*JsxChild {
}
})
}

func IsExpandoPropertyDeclaration(node *Node) bool {
Copy link
Member

Choose a reason for hiding this comment

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

the binder already has to detect these patterns, so you shouldn't need to port anything new.

edit: your explanation of why these are needed makes sense, but much of it can be simplified given how much less Corsa supports.

if node == nil {
return false
}
return IsPropertyAccessExpression(node) || IsElementAccessExpression(node) || IsBinaryExpression(node)
Copy link
Member

Choose a reason for hiding this comment

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

expando declarations should only be binary expressions now.

/** @type {string} */
f.p

is no longer supported by Corsa, and access expressions don't have a Symbol field.

return IsPropertyAccessExpression(node) || IsElementAccessExpression(node) || IsBinaryExpression(node)
}

func GetExpandoInitializer(initializer *Node, isPrototypeAssignment bool) *Node {
Copy link
Member

Choose a reason for hiding this comment

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

prototype assignments aren't supported in Corsa
(C.prototype = { ... })

func GetEffectiveInitializer(node *Node) *Expression {
if IsInJSFile(node) && node.Initializer() != nil && IsBinaryExpression(node.Initializer()) {
initializer := node.Initializer().AsBinaryExpression()
if initializer.OperatorToken.Kind == KindBarBarToken || initializer.OperatorToken.Kind == KindQuestionQuestionToken {
Copy link
Member

Choose a reason for hiding this comment

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

defaulting assignments with x.p = x.p || function() {} are not supported in Corsa.

@a-tarasyuk a-tarasyuk reopened this Jul 19, 2025
@a-tarasyuk a-tarasyuk requested review from sandersn and weswigham July 19, 2025 22:01
@jakebailey
Copy link
Member

This needs a merge from main again since one of the baselines changed via another PR.

@a-tarasyuk
Copy link
Contributor Author

@jakebailey Thanks for pointing it out. I’ve merged main

@@ -14,4 +14,7 @@
export declare function f(): I;
export {};
+//// [b.d.ts]
Copy link
Member

Choose a reason for hiding this comment

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

It's super odd that the original code did not emit a declaration file here, not sure why (the new code looks correct).

Copy link
Member

@weswigham weswigham left a comment

Choose a reason for hiding this comment

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

This is still pretty semantic in how it operates - the binder is much more syntactic now, and I think we can be more syntactic in the declaration emitter now, too, because of it. Specifically, I'd like to avoid needing a GetPropertiesOfContainerFunction emit resolver function. I'd rather we visited expression statements, looking for assignment expressions that are expando assignments (ast.GetAssignmentDeclarationKind result of Property - I think the js reparser even sets a .Type on these now!), and translate them to the equivalent expansion, instead of seeing the function declaration and semantically checking for extra properties to patch on. So, for example, we see the AST for

export function foo() { return 1 }
/** @type {"ok"} */
foo.bar = "ok"

we visit export function foo() { return 1 } and emit export function foo(): number and then we visit

/** @type {"ok"} */
foo.bar = "ok"

and emit

export namespace foo { export const bar: "ok" }

Notably, multiple expandos would emit as multiple namespaces:

export namespace foo { export const bar: "ok" }
export namespace foo { export const baz: "yes" }

by default, and then you can have a "collection" pass at the end of a scope that merges all the newly added namespaces together (or into an explicit one, if it's there and the names all work out). The only awkward thing I can see here is that the export modifier-ness of the namespace declaration relies on nonlocal information (if the host function is exported). This could be alleviated by never exporting a function with expando properties (which is still a nonlocal semantic check, unfortunately...) via an export modifier, and instead adding a export { foo } declaration - then all the declarations can always be non-exported (which is roughly how the old js declaration emitter in the node builder would have done it, and then had an extra pass to "inline" the export declarations as modifiers where possible).

@a-tarasyuk a-tarasyuk marked this pull request as draft July 27, 2025 12:15
@a-tarasyuk
Copy link
Contributor Author

@weswigham, thanks for the feedback. I've started implementing this approach and ran into a few edge cases that are worth considering:

  1. Default exports. When a function with expandos is a default export:
export default function foo() {}
foo.meta = 123;

We can't emit:

export default function foo(): void;
export namespace foo { export const meta: 123; }

As part of this, I’ve added a temporary solution using pendingDefaultExport, which defers the default export until after all expandos are emitted. However, this slightly changes the behavior for export default function declarations without expandos — they're now emitted in split form rather than as a single default-exported function.

  1. Non-exported hosts

For:

function foo() {}
foo.extra = true;

We cannot emit:

export namespace foo { const extra: true; }

At this stage, it's necessary to determine whether the host is exported, to correctly apply or omit the export modifier from the merged namespace declaration

  1. Type resolution scope

To correctly serialize the type of an expando property and avoid self-references,

declare namespace Example {
    const Bar: typeof Bar;
}

The type resolver typically relies on the enclosingDeclaration to determine symbol visibility and to avoid self-references during serialization. This means that information about the surrounding namespace scope should be available and correctly set during type serialization to ensure accurate and consistent type output.

@weswigham WDYT?

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.

implicit namespace creation omited from emitted types
4 participants