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.
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 = invalidassignment. Since that runs after the base class constructor already set the real value, it silently clobbers it back toinvalid.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
Transpiled output (
bsc1.0.0-alpha.52):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 stringreads like an ordinary, harmless type-narrowing declaration.We hit this in brighterscript-game-engine: every
SceneObjectsubclass narrows an inheriteddrawable as Drawablefield to its own drawable type (e.g.drawable as Image), which clobbers the value the baseSceneObjectconstructor 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
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: truemakes an uninitialized field declaration emitObject.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.)Environment
brighterscript: 1.0.0-alpha.52bsc --project bsconfig.json --create-package=falsewith a minimalrootDir/outDir/filesconfig, no manifest needed to observe the transpiled output.