Skip to content

Subclass field re-declared with only a type annotation (no initializer) clobbers the value the base constructor already set #1771

Description

@markwpearce

Description

When a subclass re-declares an inherited field purely to narrow its type - same field name, no initializer, just a type annotation - the transpiled subclass constructor still emits an explicit m.field = invalid assignment. Since that runs after the base class constructor already set the real value, it silently clobbers it back to invalid.

This makes "narrow an inherited field's declared type in a subclass" - a completely ordinary, common OOP pattern - a runtime footgun rather than a no-op.

Reproduction

class Animal
  sound as string

  sub new(sound as string)
    m.sound = sound
  end sub
end class

class Dog extends Animal
  ' Type-only narrowing of the inherited field - no initializer given, just a type
  ' annotation.
  sound as string

  sub new()
    super("Woof")
  end sub
end class

sub main()
  d = new Dog()
  print d.sound ' prints "" (invalid coerced to empty string by print), not "Woof"
end sub

Transpiled output (bsc 1.0.0-alpha.52):

sub __Dog_method_new()
    m.super0_new("Woof")
    m.sound = invalid   ' <- clobbers what the base constructor just set
end sub

Expected behavior

A field re-declaration with no initializer and no different default value should be purely a compile-time type annotation, with no runtime effect - the same as it already works for the reverse case (a subclass declaring a field the base class doesn't have at all gets a real initializer/assignment, which is correct and expected). Only a re-declaration that supplies its own initializer/default should emit an assignment.

Why this matters

It's easy to hit by accident and very hard to debug: the symptom shows up somewhere the field is later read (often much later, in an unrelated method), not at the point of the clobber, and nothing about the source code looks wrong - sound as string reads like an ordinary, harmless type-narrowing declaration.

We hit this in brighterscript-game-engine: every SceneObject subclass narrows an inherited drawable as Drawable field to its own drawable type (e.g. drawable as Image), which clobbers the value the base SceneObject constructor just stored. It currently goes unnoticed only because a repair step elsewhere in the codebase reassigns the field immediately after construction on every internal code path - constructing the class directly and reading the field before that repair runs exposes it. Filed as markwpearce/brighterscript-game-engine#69 before we traced it back to the compiler.

Comparison to other languages

  • TypeScript (target < ES2022, or useDefineForClassFields: false): a subclass field declaration with a type annotation and no initializer compiles to nothing at runtime - pure compile-time narrowing, no clobber. (TypeScript did effectively reintroduce this same footgun when adopting true ES2022 class-field semantics by default - useDefineForClassFields: true makes an uninitialized field declaration emit Object.defineProperty(this, field, {value: undefined}), which does stomp a value the base constructor set. It's a well-documented gotcha from that transition, which is presumably an argument for treating this as something worth being deliberate about rather than defaulting into.)
  • Java/C#/Kotlin/Swift: redeclaring an inherited field in a subclass ("field hiding") creates a genuinely separate storage slot rather than re-initializing the parent's, so there's no shared slot to clobber in the first place.

Environment

  • brighterscript: 1.0.0-alpha.52
  • Reproduced via bsc --project bsconfig.json --create-package=false with a minimal rootDir/outDir/files config, no manifest needed to observe the transpiled output.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions