skip to content

In TypeScript, this class compiles with no error: `class G { greeting = this.build(); constructor(private name: string) {} build() { return `hi ${this.name}`; } }`. What does `new G("ada").greeting` hold, and what decides the answer?

level: seniorimportance: should knowfreq 38%

answer

  1. two class-field emit modes
  2. initializers can run before the constructor body
  3. useDefineForClassFields decides it
  4. define semantics: field first, argument later
  5. TS2729 only catches the direct read

basics

~20 s

It depends on how class fields are compiled. Under define semantics (useDefineForClassFields on) field initializers run before the constructor body, so the parameter property is still undefined and greeting becomes "hi undefined"; with assignment semantics it is "hi ada".

solid answer

~50 s

The answer flips with `useDefineForClassFields`. When it is on — the default once `target` is ES2022 or higher — class fields follow native define semantics: every declared field, including the one generated for the parameter property, is defined and its initializer evaluated *before* the constructor body runs. The parameter property's assignment lives in that body, so `this.name` is still `undefined` when `build()` is called and `greeting` ends up `"hi undefined"`. With the flag off, the compiler emits the parameter-property assignment first and the field initializers after it inside the same constructor, so `greeting` is `"hi ada"`. TypeScript does flag the direct form, `greeting = \`hi ${this.name}\``, as TS2729 `Property 'name' is used before its initialization` — but routing the read through a method hides it from that check, which is why this version compiles clean and still misbehaves.

code

javascript · 19 lines
javascript
// define semantics (useDefineForClassFields: true, target >= ES2022)
class Define {
  name;
  greeting = this.build();
  constructor(name) { this.name = name; }
  build() { return `hi ${this.name}`; }
}

// assignment semantics (useDefineForClassFields: false)
class Assign {
  constructor(name) {
    this.name = name;
    this.greeting = this.build();
  }
  build() { return `hi ${this.name}`; }
}

console.log(new Define("ada").greeting); // hi undefined
console.log(new Assign("ada").greeting); // hi ada

go deeper

for a junior

Know that a field initializer and a constructor assignment do not necessarily run in source order, and that reading a constructor argument from a field initializer is risky. Do the derivation in the constructor body.

for a middle

Explain both emit modes concretely: define semantics evaluate field initializers before the constructor body, assignment semantics put parameter-property assignments first. Say which one target selects by default.

for a senior

Diagnose it in production terms: a clean-compiling class producing undefined after a target bump, and the fact that TS2729 only guards the direct read. Show the ordering-proof rewrite.

for a principal

Own the migration risk. Raising target flips field semantics repo-wide, so decide whether to pin useDefineForClassFields explicitly, how to audit classes that derive fields from constructor arguments, and how to prevent the pattern returning.

## Two ways a class field can be compiled Every TypeScript class with fields is compiled under one of two models, selected by `useDefineForClassFields`: - **Assignment semantics** (`false`): field initializers are turned into `this.x = ...` statements inside the constructor. - **Define semantics** (`true`): fields are emitted as real JavaScript class fields, which the runtime *defines* on the instance before the constructor body executes. The default follows `target`: define semantics from ES2022 upward, assignment semantics below. That is the single most important fact behind this question — the same source compiles two different ways depending on a setting most developers never look at. ## What each mode emits Source: ```ts class G { greeting = this.build(); constructor(private name: string) {} build() { return `hi ${this.name}`; } } ``` With `useDefineForClassFields: false`: ```js class G { constructor(name) { this.name = name; // parameter property first this.greeting = this.build(); // then field initializers } build() { return `hi ${this.name}`; } } ``` `new G("ada").greeting` is `"hi ada"`. With `useDefineForClassFields: true`: ```js class G { name; // generated declaration for the parameter property greeting = this.build(); // runs before the constructor body constructor(name) { this.name = name; // too late for greeting } build() { return `hi ${this.name}`; } } ``` `new G("ada").greeting` is `"hi undefined"`. ## Why the order inverts Under assignment semantics the compiler is free to sequence the statements, and it puts the parameter-property assignments *before* the field initializers — which is what most people intuitively expect from reading the source, since the constructor is where the argument arrives. Under define semantics the compiler has no such freedom. Field definition is a runtime step the engine performs at a fixed point: for a base class, immediately before the constructor body; for a derived class, immediately after `super(...)` returns. The generated `name;` declaration for the parameter property is defined there too, initialized to `undefined`, in declaration order. The actual value only arrives when the constructor body runs, and the body runs last. So under define semantics a parameter property is always assigned *after* every field initializer in the class. ## The compiler catches only the obvious form Write the read directly and TypeScript stops you: ```ts class G { greeting = `hi ${this.name}`; // TS2729: Property 'name' is used before its initialization. constructor(private name: string) {} } ``` TS2729 is a syntactic, single-hop check on the initializer expression. The moment the read goes through a method call, a helper function, a getter, or a callback, the check sees nothing suspicious and the program compiles cleanly. That is exactly the shape in the question, and it is why this bug reaches production: the source looks fine, the type-checker is silent, and the behaviour depends on a compiler option. ## Derived classes The same rule holds with inheritance, with `super(...)` as the pivot. Given: ```ts class Base { constructor(protected id: string) {} } class Sub extends Base { label = "L"; constructor(id: string, private extra: number) { super(id); } } ``` under define semantics `Sub`'s fields — the generated `extra;` and then `label`— are defined right after `super(id)` returns, and `this.extra = extra;` runs afterwards in the body. Under assignment semantics the constructor reads `super(id); this.extra = extra; this.label = "L";`. Note also that `Base`'s own parameter property is assigned during `super(id)`, so it *is* available to `Sub`'s field initializers in both modes. ## How to avoid the trap Three defences, in order of preference: 1. **Do not read a parameter property from a field initializer.** If a field's value derives from a constructor argument, compute it in the constructor body after the arguments are in place: `constructor(private name: string) { this.greeting = this.build(); }` with `greeting` declared but uninitialized. 2. **Make the derived value a getter** — `get greeting() { return this.build(); }` — so it is computed on read and no ordering exists to get wrong. 3. **Know which mode your project is in.** Check `target` and any explicit `useDefineForClassFields`, and be aware that raising `target` to ES2022 silently flips the semantics of every class in the codebase. ## What the interviewer is looking for A weak answer says `"hi ada"` and stops, because that is what reading the source top-to-bottom suggests. A strong answer refuses to give a single value: it names `useDefineForClassFields`, explains that define semantics run field initializers before the constructor body while assignment semantics interleave them after the parameter-property assignments, and adds that the type-checker's TS2729 guard only covers a direct read. That last point is what separates someone who has read about the flag from someone who has debugged this.

  • Why does TypeScript report TS2729 for `greeting = \`hi ${this.name}\`` but stay silent when the read goes through `this.build()`?
    TS2729 is a single-hop syntactic check on the initializer expression: it sees `this.name` written directly and knows the parameter property has not been assigned yet. It does not follow calls, so a method, getter, helper, or callback that reads `this.name` is invisible to it. The check is a convenience, not a guarantee — the ordering rule is what you actually have to reason about.
  • Where exactly do field initializers run in a derived class under define semantics?
    Immediately after `super(...)` returns and before the rest of the constructor body. So a subclass field initializer can see values a base-class parameter property assigned during `super(...)`, but not values the subclass's own parameter properties assign, since those statements come later in the body. That asymmetry surprises people who assume all initialization is one phase.
  • What is the safest way to compute a field from a constructor argument?
    Declare the field without an initializer and assign it in the constructor body after the parameters are available, or expose the value as a getter so it is computed on demand. Both are immune to the emit mode. Reserve field initializers for values that depend on nothing but constants and other already-initialized fields.
  • What happens to existing classes when a project raises `target` from ES2021 to ES2022?
    `useDefineForClassFields` flips to true by default, so every class in the codebase changes from assignment to define semantics. Field initializers that read parameter properties silently start seeing `undefined`, and fields that shadowed inherited accessors begin overwriting them instead. Treat the target bump as a behavioural change and set the flag explicitly if you want to stage it.

saying these in an interview costs you the question

  • Assumes the constructor body always runs before field initializers
  • Thinks the compiler always reports reading an unassigned parameter property
  • Says useDefineForClassFields only changes syntax, not evaluation order
  • Believes the fix is a definite-assignment ! on the field
  • Answers with one value without naming the flag that decides it

context