skip to content

What does the TypeScript compiler option `useDefineForClassFields` change about how class fields are emitted, what is its default, and what kind of working code breaks when it becomes true?

level: middleimportance: should knowfreq 34%

answer

  1. set versus define
  2. standard semantics won
  3. default follows the target
  4. declare emits nothing
  5. setters stop being called

basics

~20 s

useDefineForClassFields switches class field emit from plain assignment to Object.defineProperty semantics, matching the ECMAScript standard. It defaults to true when target is ES2022 or higher. Fields declared without an initializer then overwrite inherited values with undefined, and they shadow base-class accessors instead of calling them.

solid answer

~50 s

TypeScript's original class fields were emitted as assignments in the constructor (`this.x = 1`), which run setters and are simply overwritten if a base class already set the property. The ECMAScript standard chose different semantics: a field is *defined* on the instance with `Object.defineProperty`, which never calls a setter and always creates an own property. `useDefineForClassFields` selects between them; it defaults to true when `target` is ES2022 or higher and false below that. Two things break when it flips on. First, a field **declaration with no initializer** now emits a definition of `undefined`, wiping a value the base constructor assigned — the fix is the `declare` modifier, which tells the compiler the field is type-only and emits nothing. Second, a derived field can no longer override a base-class accessor by assignment; the compiler reports that case rather than silently changing behaviour. Because the default follows `target`, raising the target can change runtime behaviour, not just syntax.

code

javascript · 20 lines
javascript
// assignment semantics (useDefineForClassFields: false)
class A {
  constructor() {
    this.x = 1;
  }
}

// define semantics (useDefineForClassFields: true)
class B {
  constructor() {
    Object.defineProperty(this, "x", {
      enumerable: true,
      configurable: true,
      writable: true,
      value: 1,
    });
  }
}

console.log(new A().x, new B().x);

go deeper

for a junior

Know that this option decides whether a class field is emitted as a plain assignment or via Object.defineProperty, and that the standard chose the define form.

for a middle

Explain both semantics, state that the default follows target at ES2022, and show the two breakages — uninitialized re-declarations and fields shadowing accessors — plus the declare fix.

for a senior

Recognise this as the exception to "target only changes syntax" and drive the migration deliberately: flip the flag on its own change, fix each site, and keep the behavioural diff reviewable.

for a principal

Set the org-wide position on aligning with standard class-field semantics, and weigh the one-off migration cost against staying pinned below ES2022 indefinitely.

## Two possible meanings for one syntax TypeScript had class fields years before they were standardised, and the two designs disagree about what a field declaration *does*. - **Assignment semantics (`[[Set]]`)** — the historical TypeScript behaviour. A field with an initializer becomes `this.x = 1` in the constructor. If a property of that name already exists on the prototype chain as an accessor, the assignment **calls the setter**. A field with no initializer emits nothing at all. - **Define semantics (`[[Define]]`)** — what ECMAScript standardised. The field is installed with `Object.defineProperty`, creating an own, enumerable, writable data property. Setters are **never** consulted, and a declaration with no initializer still defines the property, with the value `undefined`. ```javascript // assignment semantics class A { constructor() { this.x = 1; } } // define semantics class B { constructor() { Object.defineProperty(this, "x", { enumerable: true, configurable: true, writable: true, value: 1, }); } } ``` `useDefineForClassFields` picks which one `tsc` emits. ## The default is tied to target The option defaults to **true when `target` is ES2022 or higher (including `esnext`)** and false below that. That coupling exists so that when you compile to a runtime whose own native class fields use define semantics, TypeScript's emit agrees with it — otherwise the same source would behave differently before and after downleveling. The consequence is the thing to say out loud in an interview: **raising `target` can change runtime behaviour, not just syntax.** A `target` bump from `es2021` to `es2022` is the usual way teams meet this flag, often through a mysterious runtime failure rather than a compile error. ## Breakage one: the uninitialized re-declaration A common pattern re-declares an inherited property to give it a narrower type: ```typescript class Animal { constructor(public name: string) {} } class Dog extends Animal { name: string; // narrowing / documenting the inherited field } ``` Under assignment semantics that declaration emitted nothing, so `Dog` inherited whatever `Animal`'s constructor assigned. Under define semantics it emits a definition after `super()` returns, and the instance ends up with `name === undefined`. The fix is the `declare` modifier: ```typescript class Dog extends Animal { declare name: string; // type-only; emits nothing } ``` `declare` states that the field exists for the type system and that some other code is responsible for it at runtime. It is the correct tool whenever a declaration is documentation rather than initialisation. ## Breakage two: fields versus accessors The second classic pattern overrides a base-class accessor with a derived field: ```typescript class Base { private stored = 0; get value() { return this.stored; } set value(v: number) { this.stored = v; } } ``` Under assignment semantics, a derived class writing `value = 5` invoked the setter. Under define semantics it installs an own data property that **shadows** the accessor entirely — the setter never runs, and later reads bypass the getter. This is a genuine behaviour change, so the compiler reports the conflict rather than quietly emitting the new meaning. When you really want the setter, do the assignment in the constructor body instead of as a field declaration; the constructor body is unaffected by this flag. ## Related consequences - **Ordering and visibility.** With define semantics every declared field exists on the instance immediately after construction, even undeclared-value ones, so `Object.keys(instance)` and JSON serialisation may include keys that previously were absent. - **Parameter properties are unaffected.** `constructor(private id: string)` is a TypeScript-only shorthand that always emits an assignment in the constructor body. - **Decorators.** The two decorator systems observe fields differently; do not reason about one from the other. Whichever you use, the field semantics chosen by this flag is what the decorated member ends up with. ## How to answer well Name both semantics precisely, tie the default to `target`, and give at least one concrete breakage plus its fix (`declare`). The strongest version of the answer notes that this is a rare case where a `target` change alters runtime behaviour — the usual mental model of "target only changes syntax" has exactly this exception.

  • What does the `declare` modifier on a class field actually emit?
    Nothing. It tells the type system the property exists with that type while asserting that something else — a base constructor, a framework, a mixin — installs it at runtime. That is exactly what you need when a re-declaration is documentation rather than initialisation, and it is the standard fix once define semantics is in force.
  • Are constructor parameter properties affected by this flag?
    No. `constructor(private id: string)` is TypeScript-only shorthand that always emits a plain assignment inside the constructor body, so setter behaviour and ordering there are unchanged. Only declared class *fields* switch between set and define semantics.
  • Why is the default tied to target rather than being a fixed value?
    So that emit matches the runtime. At ES2022 and above the engine has native class fields with define semantics, and TypeScript emits fields natively — using assignment semantics would make the same source behave differently before and after downleveling. Below that target, the historical behaviour is preserved for compatibility.
  • How would you migrate a large codebase that breaks when this flag turns on?
    Enable it explicitly rather than letting a target bump do it silently, then work through the errors: add `declare` to re-declarations that only narrow or document an inherited field, and move genuine setter-invoking writes from field initializers into the constructor body. Doing it as its own change keeps the behavioural diff reviewable.

saying these in an interview costs you the question

  • Thinks the flag only affects type checking, not emit
  • Says class fields always ran setters in JavaScript
  • Assumes target changes can never alter runtime behaviour
  • Confuses the declare modifier with a declaration file
  • Believes parameter properties follow the same semantics

context