A derived class declares a field the base class already assigned, and the value disappears. No type error, no runtime exception, just undefined where the constructor put a real string. I hit this while narrowing phase on GitProxy’s plugin classes, and it is one of those TypeScript corners that looks like a type-only change until you print the object.
export abstract class ActionPlugin extends ProxyPlugin {
readonly phase: PushPhase | PullPhase;
constructor(exec: ProcessorExec, options: ActionPluginOptions & { phase: PushPhase | PullPhase }) {
super();
this.phase = options.phase;
}
}
class PushActionPlugin extends ActionPlugin {
readonly phase: PushPhase; // looks like a type narrowing
constructor(exec: ProcessorExec, options: PushPluginOptions = {}) {
super(exec, { ...options, phase: options.phase ?? PushPhase.AFTER_PERMISSIONS });
}
}
After new PushActionPlugin(fn, { phase: 'AFTER_DIFF' }), plugin.phase is undefined. The base constructor ran. The assignment happened. Then it was overwritten.
Field initialisers run after super(), and they emit real code
Public class fields are initialised on the instance after the parent constructor returns. That is specified behaviour, not a TypeScript quirk. An uninitialised field still participates: it is defined on the instance and set to undefined.
TypeScript’s useDefineForClassFields flag controls how that initialisation is emitted. When the flag is on, which is the default once target is ES2022 or newer, or when module is NodeNext, an uninitialised field declaration still emits a defineProperty (or the equivalent assignment under the newer emit) that writes undefined onto the instance. So the sequence is:
PushActionPlugin’s constructor callssuper(...).ActionPluginassignsthis.phase = 'AFTER_DIFF'.- Control returns to the derived constructor.
- The derived field declaration runs and writes
undefined.
readonly does not help. It only prevents later assignment in the type checker. The field initialiser is not a later assignment as far as TypeScript is concerned; it is the declaration itself, and it is allowed to run even when the constructor cannot write to the field afterwards. That is why you cannot “put the value back” in the derived constructor either.
The TypeScript 3.7 release notes introduced both the flag and the escape hatch, and they are still the clearest write-up of the interaction.
declare narrows the type and emits nothing
The declare modifier on a class field is a type-only annotation. It does not emit an initialiser, so it does not overwrite anything:
class PushActionPlugin extends ActionPlugin {
isGitProxyPushActionPlugin = true;
declare readonly phase: PushPhase;
constructor(exec: ProcessorExec, options: PushPluginOptions = {}) {
super(exec, { ...options, phase: options.phase ?? PushPhase.AFTER_PERMISSIONS });
}
}
Now phase is PushPhase on the subclass, PullPhase stays rejected by the type checker, and the value the base constructor stored is the value you read back. Same pattern for chains if the field only exists on the push subclass and is assigned in that constructor.
The same trap eats exec
GitProxy’s docs tell plugin authors to subclass and define exec as their own property when they have state. Class fields in the derived class initialise after super() returns, so a subclass written that way silently replaces whatever the base constructor assigned, including any metadata you had stapled onto the function. That is one reason the chain builder now wraps exec after construction is finished rather than trusting properties set in the base constructor. I walked through that wrapping, and why it also preserves this, in losing this by passing a method as a callback.
How to see it in one minute
If you want to confirm the emit rather than take my word for it:
class Base {
phase: string;
constructor() {
this.phase = 'AFTER_DIFF';
console.log('in Base', this.phase);
}
}
class Derived extends Base {
phase: string;
}
console.log('after new', new Derived().phase);
With useDefineForClassFields on, that prints in Base AFTER_DIFF and then after new undefined. Add declare on the derived field and the second line is AFTER_DIFF.
The takeaway
An uninitialised field declaration in a subclass is not a type narrowing. Under useDefineForClassFields it is a write of undefined that runs after super() and wipes whatever the base constructor stored. Use declare when you only want the narrower type. The plugin classes that hit this are part of how GitProxy’s plugin system is designed.