A TypeScript class targeting ES2022 subclasses another and redeclares an inherited property just to give it a narrower type — `class Dog extends Animal { name: string; }` — and at run time `name` comes out `undefined`. What is the compiler doing, and how do you fix it?
answer
- a declaration is not free
- define, not assign
- runs after super returns
- the target default flipped it on
- one modifier suppresses the emit
basics
~20 sThe redeclared field is emitted as a real field definition, so after super() runs it defines name on the instance as undefined, overwriting the value the base constructor set. Mark it declare name: string so the compiler emits nothing for it.
solid answer
~50 sA field declaration in a class body is not free — it emits code. With `useDefineForClassFields` on, which is the default when `target` is ES2022 or higher in modern TypeScript, a declaration without an initializer is emitted as a real field definition on the instance, valued `undefined`. Ordering does the rest: `super(...)` runs first and the base constructor assigns `name`, then the subclass's own field definitions run and overwrite it with `undefined`. The compiler does warn about exactly this — it tells you the property will overwrite the base property and suggests either an initializer or a `declare` modifier. The fix is `declare name: string`, which says "this property exists and I am only restating its type" and emits nothing at all. Alternatively, drop the redeclaration, or give it a real initializer if you genuinely meant to set it.
code
typescript · 26 linesclass Animal {
name: string;
constructor(name: string) {
this.name = name;
}
}
class Broken extends Animal {
// Under define semantics this emits a field definition that runs
// after super() and overwrites the base value with undefined.
name: string;
constructor() {
super("Rex");
}
}
class Fixed extends Animal {
// `declare` emits nothing: it only restates the type.
declare name: string;
constructor() {
super("Rex");
}
}
console.log(new Broken().name); // undefined
console.log(new Fixed().name); // "Rex"go deeper
Take away one rule: in a TypeScript class body a field declaration can create a real property at run time, so restating an inherited field is not a free annotation.
Explain the two emit strategies for class fields, when define semantics apply based on the compile target, and walk the ordering that puts the subclass's field definition after the base constructor's assignment.
Diagnose it from the symptom alone, reach for declare rather than lowering the target, and recognise the harder accessor-shadowing variant where a base setter silently stops running.
Own the migration: decide when a codebase moves to define semantics, how you sweep for redeclarations and accessor shadowing before flipping it, and how you keep the compiler's warning from being suppressed as noise.
## Two emit strategies for a class field A class field declaration can be compiled two ways, and TypeScript picks between them with `useDefineForClassFields`. With the option **off**, fields are emitted as assignments in the constructor — `this.name = ...` — and a declaration with no initializer emits *nothing at all*. This was TypeScript's original behaviour, from before class fields existed in JavaScript. With the option **on**, fields follow the standard JavaScript semantics: each declared field is *defined* on the instance, and one without an initializer is defined with the value `undefined`. Nothing is skipped; a bare declaration is still a runtime operation. In modern TypeScript the option defaults to `true` when `target` is ES2022 or higher (including `esnext`), and `false` for lower targets. That default flip is why this bug shows up when a project raises its target rather than when anyone touches the class. ## Why the redeclaration wipes the value Order of operations inside `new Dog()`: 1. `Dog`'s constructor runs `super(...)`. 2. `Animal`'s constructor runs and assigns `this.name`. 3. Control returns to `Dog`, and *now* `Dog`'s own field definitions execute. 4. `name: string` — no initializer — defines `name` as `undefined` on the instance. 5. The rest of `Dog`'s constructor body runs. The subclass author thought they were writing a type annotation. They were writing an instruction to create a property. The base class's work is discarded between steps 2 and 4. ```ts class Animal { name: string; constructor(name: string) { this.name = name; } } class Dog extends Animal { name: string; // emits a field definition -> undefined constructor() { super("Rex"); } } new Dog().name; // undefined ``` ## The compiler tells you This is not silent. When the define semantics are in force, TypeScript reports that the property will overwrite the base property in the base class, and suggests adding an initializer if that is intentional, or a `declare` modifier — or removing the redundant declaration — if it is not. The failure mode in real projects is that the message is read as a style nag and suppressed. ## The fix: declare ```ts class Dog extends Animal { declare name: string; // type-only restatement, emits nothing constructor() { super("Rex"); } } ``` `declare` on a class property tells the compiler the property exists at run time by some other means and that it must not emit anything for this declaration. It is the correct tool whenever you are restating a member the base class already creates — most often to narrow its type in the subclass, which is legal as long as the narrower type is assignable to the base's. It is also the standard fix for the same problem coming from the other direction: a framework or a decorator populates the property, and you only want the type on record. ## The accessor variant The same mechanism breaks a subtler case. If the base class exposes the member as a getter/setter pair on the prototype, a subclass field declaration under define semantics creates an own data property on the instance that *shadows* the accessor — and, because defining is not assigning, the base setter is never called. Code that expected the setter's validation or side effect gets neither, and reads now hit the shadowing own property instead of the getter. `declare` fixes this one too. ## Judgement Two things are worth saying out loud. First, this is the standards-compliant behaviour: the define semantics are what JavaScript itself specifies for class fields, and TypeScript's older assignment emit was the deviation. Turning the option off to make a bug go away is buying time against the language, not fixing anything. Second, this is exactly the kind of defect the type-erasure model is supposed to prevent — a declaration in the class body that looks like an annotation but is not. The habit to build is: in a class body, a field declaration is a runtime instruction unless you write `declare`.
- Would lowering the compile target to ES2015 make the bug disappear, and is that a good fix?It would, because `useDefineForClassFields` defaults to false below ES2022 and a declaration with no initializer then emits nothing. It is a bad fix: define semantics are what JavaScript itself specifies for class fields, so you would be pinning the whole project to legacy emit to paper over one class. Use `declare`.
- What happens if the base class exposes the member as a getter/setter pair instead of a plain field?Worse, and more quietly. The subclass declaration defines an own data property on the instance that shadows the prototype accessor, and because defining is not assigning, the base setter never runs — its validation or side effects are skipped, and later reads hit the shadowing property rather than the getter. `declare` is again the fix.
- When is redeclaring an inherited property in a subclass legitimate at all?When you want a narrower type on record — the base declares `payload: object` and the subclass knows it is always an array. That is a pure type refinement, so it belongs behind `declare`. If you also want a different runtime value, write a real initializer or assign in the constructor and accept that you are overwriting.
saying these in an interview costs you the question
- Assumes a field declaration with no initializer emits nothing
- Thinks the redeclaration is only a type annotation
- Blames the base constructor's assignment order
- Turns off the option instead of using declare
- Believes declare changes the emitted output