Skip to content

Emit JS this properties in declarations matching tsc's serializePropertySymbol#4396

Draft
Copilot wants to merge 10 commits into
mainfrom
copilot/behavior-difference-state-emit
Draft

Emit JS this properties in declarations matching tsc's serializePropertySymbol#4396
Copilot wants to merge 10 commits into
mainfrom
copilot/behavior-difference-state-emit

Conversation

Copilot AI commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Declaration emit skipped JS this-assigned properties when a base class already declared the same property. This made React-style this.state = ... initialization disappear from .js declaration output, while tsc still emits it.

  • Declaration emit

    • The skip decision for JS this.<prop> = ... expando assignments now mirrors the checker's serializePropertySymbol (EmitResolver.IsThisPropertyAssignmentDeclarationRedundant), rather than a declaration-based heuristic gated on the constructor.
    • The assignment is omitted only when the synthesized member would be redundant with an extends base member:
      • Skip when the inherited member is identical to the assigned one (same readonly-ness, optionality, and identical type).
      • Always skip when the base member is an accessor (it merges oddly with value assignments and runs via the accessor at runtime).
      • Always skip when the base member is a method/function (this-expando props carry the ReplaceableByMethod contract, so this.method = this.method.bind(this) must not override the base method).
    • The skip is no longer gated on being inside a constructor — this. assignments add JS expando props in any method, and are emitted/skipped purely on the conditions above.
    • Only extends base types are considered. implements-clause properties are always emitted (matching tsc), since they are not inherited and the class must (re)declare them.
  • Regression coverage

    • Adds a compiler test covering matching .js and .ts inputs.
    • Verifies a JS constructor assignment emits state (member type differs from the base), while the TS constructor assignment remains omitted.
    • Covers a non-constructor (method) assignment skipped via type identity with the base.
    • Covers a constructor assignment to a base accessor (skipped) and a method rebind (this.method = this.method.bind(this)) to a base method (skipped).
    • Covers an assignment to a property declared by an implements clause type, which is emitted (not skipped) and produces no declaration error.
export class C2 extends Component {
    constructor() {
        super({});
        this.state = { count: 0 };
    }
}

Now emits in .js declaration output:

export declare class C2 extends Component {
    state: {
        count: number;
    };
    constructor();
}

Copilot AI and others added 4 commits June 22, 2026 16:28
Co-authored-by: jakebailey <5341706+jakebailey@users.noreply.github.com>
Co-authored-by: jakebailey <5341706+jakebailey@users.noreply.github.com>
Co-authored-by: jakebailey <5341706+jakebailey@users.noreply.github.com>
Co-authored-by: jakebailey <5341706+jakebailey@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix behavior difference for .state emit in react component Emit JS constructor this properties in declarations Jun 22, 2026
Copilot AI requested a review from jakebailey June 22, 2026 16:38
@jakebailey jakebailey requested a review from Copilot June 22, 2026 17:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR adjusts declaration emit so that this.<prop> = ... assignments inside JavaScript class constructors can synthesize class members in the emitted .d.ts even when a base class already declares the same property, aligning behavior with tsc for React-style this.state = ... initialization.

Changes:

  • Update declaration transformer logic to not skip base-property this assignments when they occur in a constructor.
  • Add a compiler regression test covering parallel .js and .ts inputs for the same pattern.
  • Add new reference baselines (.js, .types, .symbols) validating the emitted declaration differences.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
internal/transformers/declarations/transform.go Allows constructor this assignments to synthesize properties even if the base type already declares the property.
testdata/tests/cases/compiler/jsDeclarationEmitThisAssignmentWithBaseProperty.ts New regression test covering JS-vs-TS behavior for constructor this.state assignment with a base state property.
testdata/baselines/reference/compiler/jsDeclarationEmitThisAssignmentWithBaseProperty.js New output + declaration baseline validating emitted state for JS C2 but not TS C2.
testdata/baselines/reference/compiler/jsDeclarationEmitThisAssignmentWithBaseProperty.types New types baseline validating inferred type flow for the assignment pattern.
testdata/baselines/reference/compiler/jsDeclarationEmitThisAssignmentWithBaseProperty.symbols New symbols baseline validating symbol ownership differences between JS and TS scenarios.

Comment on lines +2069 to +2071
decls := tx.resolver.GetBaseDeclarationsForPropertyDeclaration(node)
if len(decls) > 0 {
break caseBlock // property lightly overrides a property in a base type - skip it
if len(decls) > 0 && !ast.IsConstructorDeclaration(thisContainer) {
break caseBlock // skip non-constructor assignments to base properties

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Now skip when any base declaration is an accessor, even in constructors. Fixed in 9bf4b46.

Comment on lines +20 to +25
export class C2 extends Component {
constructor() {
super({});
this.state = { count: 0 };
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added a non-constructor (method) assignment case (C3), plus a constructor-to-accessor case (C4), both verified skipped in the baselines. Fixed in 9bf4b46.

Copilot AI and others added 2 commits June 22, 2026 17:23
Even in a constructor, assigning to an inherited accessor via `this.prop =
...` is a setter call at runtime, so synthesizing a data property override
can produce an invalid .d.ts. Continue skipping when any base declaration is
an accessor. Also extend the regression test with non-constructor and
accessor cases.

Co-authored-by: jakebailey <5341706+jakebailey@users.noreply.github.com>
Improves readability of the compound condition guarding declaration emit
for this-property assignments.

Co-authored-by: jakebailey <5341706+jakebailey@users.noreply.github.com>
@weswigham

Copy link
Copy Markdown
Member

@copilot you need to fix the TODO at the bottom of GetBaseDeclarationsForPropertyDeclaration for this to be more right - base props from implements clauses matter.

GetBaseDeclarationsForPropertyDeclaration only returned the first extends
base type's declarations and ignored implements clauses. Collect property
declarations from all base types and implements clause types so declaration
emit of JS this-property assignments accounts for implemented members.

Co-authored-by: weswigham <2932786+weswigham@users.noreply.github.com>

Copilot AI commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

@copilot you need to fix the TODO at the bottom of GetBaseDeclarationsForPropertyDeclaration for this to be more right - base props from implements clauses matter.

GetBaseDeclarationsForPropertyDeclaration now collects declarations from all base types and from implements clause types (via a new getImplementsTypes checker helper), rather than returning only the first base type's declarations. Added a test case where a JS class assigns to a property declared by an implemented type. Fixed in 8c... (latest commit).

Copilot AI requested a review from weswigham June 23, 2026 21:19
}


//// [DtsFileErrors]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@copilot you've introduced a dts file error, which means something is wrong.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5304bef. The invalid .d.ts came from feeding implements-clause property declarations into the override-compat skip logic: C5's value is an accessor on the implemented WithAccessor, so it got skipped, leaving C5 missing a required member (TS2720). tsc always emits implements-clause properties and only skips writes to extends base members (inherited accessors / non-constructor writes). I reverted the base-declaration lookup to extends base types only, so C5 now emits value: number; with no dts error, and the C2/C3/C4 cases are unchanged.

@weswigham

Copy link
Copy Markdown
Member

@copilot actually, go back and just remove the TODO - classes need to redeclare interface-inherited members anyway.

decls := tx.resolver.GetBaseDeclarationsForPropertyDeclaration(node)
if len(decls) > 0 {
break caseBlock // property lightly overrides a property in a base type - skip it
shouldSkipAssignment := len(decls) > 0 && (!ast.IsConstructorDeclaration(thisContainer) || core.Some(decls, ast.IsAccessor))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this does need to include skipping inherited methods, too, though - this-expando props have the SymbolFlagsReplaceableByMethod tag, so a this.method = this.method.bind({}) in a subclass shouldn't override the method definition in the superclass. Skipping accessors is more of a necessity, since the symbol merges with the accessor and just... does weird stuff (and at runtime it just uses the accessor, ofc).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I also don't think skipping expandos outside the constructor is at-all correct; we recognize this. assignments as adding js expando props in any method.

@weswigham weswigham Jun 23, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The exact condition we used in strada (see serializePropertySymbol in checker.ts) was

                    if (
                        p.flags & SymbolFlags.Prototype || p.escapedName === "constructor" ||
                        (baseType && getPropertyOfType(baseType, p.escapedName)
                            && isReadonlySymbol(getPropertyOfType(baseType, p.escapedName)!) === isReadonlySymbol(p)
                            && (p.flags & SymbolFlags.Optional) === (getPropertyOfType(baseType, p.escapedName)!.flags & SymbolFlags.Optional)
                            && isTypeIdenticalTo(getTypeOfSymbol(p), getTypeOfPropertyOfType(baseType, p.escapedName)!))
                    ) {
                        return [];
                    }

which is to say, skip prototype, skip constructor, and skip properties identical to those in the base type.

If we don't wanna have to come back to this again - we probably wanna replicate those conditions here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@copilot adjust the condition to match the above, rather than looking at the declaration kinds of the symbol or thisContainer to try and approximate the same thing. You can and should remove GetBaseDeclarationsForPropertyDeclaration and replace it with a new resolver method - IsPropertyIdenticalToBaseTypeProperty that implements the above logic.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reworked in 2559767 to replicate the strada serializePropertySymbol conditions in IsThisPropertyAssignmentDeclarationRedundant:

  • No longer gated on the constructor — this. assignments in any method are treated as expando props.
  • Skips when the inherited member is identical (same readonly-ness, optionality, and identical type).
  • Always skips inherited accessors and inherited methods/functions (so this.method = this.method.bind(this) no longer overrides the base method).
  • Only extends base types are considered.

Added regression cases: C3 (method assignment, skipped via type identity) and C6 (method rebind, skipped via the method check). I kept the accessor/method overrides as skips rather than implementing the accessor-emit "bonus" branch.

Skip decisions for JS constructor `this` assignments are based only on
extends base declarations; implements-clause props are always emitted, as
tsc does. Reverting the implements collection fixes the invalid C5 .d.ts.

Co-authored-by: jakebailey <5341706+jakebailey@users.noreply.github.com>
Co-authored-by: weswigham <2932786+weswigham@users.noreply.github.com>

Copilot AI commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

@copilot actually, go back and just remove the TODO - classes need to redeclare interface-inherited members anyway.

Done in 2559767. Removed the TODO/implements path: the skip logic now only consults extends base types via the new IsThisPropertyAssignmentDeclarationRedundant, so implements-clause members are always (re)emitted by the class.

Copilot AI changed the title Emit JS constructor this properties in declarations Emit JS this properties in declarations matching tsc's serializePropertySymbol Jun 23, 2026
Copilot AI requested a review from weswigham June 23, 2026 22:04
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.

Behavior difference: .state emit is missing on react component

4 participants