Skip to content
JavaScript js classes 4 min read

Class Fields & Initialization

Before public class fields landed (ES2022, widely shipped well before that in browsers and Node), every piece of instance state had to be assigned inside the constructor. Class fields let you declare and initialize instance properties directly in the class body, right where you can see them. This makes a class’s shape obvious at a glance, reduces constructor boilerplate, and — when combined with arrow functions — gives you methods that stay bound to the instance no matter how they’re called.

Declaring public instance fields

A public field is written in the class body as a property name, optionally followed by = and an initializer expression. Each field is created on every new instance, and the initializer runs once per instance. You do not use let, const, var, or this. — just the name.

class Counter {
  count = 0;
  label = "clicks";
  history = [];

  increment() {
    this.count += 1;
    this.history.push(this.count);
  }
}

const c = new Counter();
c.increment();
c.increment();
console.log(c.count, c.label, c.history);

Output:

2 clicks [ 1, 2 ]

A field with no initializer (name;) is still created and set to undefined, which usefully documents that the property exists. Note that history = [] produces a fresh array per instance — initializers run per object, so two Counter instances never share the same array.

Initialization order vs the constructor

Field initializers do not run at some vague “class load” time — they run during construction, in source order, and the timing relative to the constructor matters. In a base class (one with no extends), fields are initialized at the very start of construction, before the constructor body executes. So by the time your constructor code runs, every field already holds its initial value.

class Config {
  retries = 3;
  timeout = this.retries * 1000; // can reference earlier fields

  constructor(overrides = {}) {
    console.log("before:", this.retries, this.timeout);
    Object.assign(this, overrides);
    console.log("after:", this.retries, this.timeout);
  }
}

const cfg = new Config({ retries: 5 });

Output:

before: 3 3000
after: 5 3000

Two things to notice. First, a later initializer can read an earlier field (timeout uses this.retries) because fields run top to bottom. Second, the constructor sees fully-initialized fields, so passing retries: 5 overrides the value after timeout was already computed from the default — initializers do not re-run.

In a derived class (one that uses extends), instance fields are initialized immediately after super() returns. That means you must call super() before this — and your subclass fields — are available.

class Animal {
  legs = 4;
  constructor(name) {
    this.name = name;
  }
}

class Dog extends Animal {
  species = "dog"; // initialized right after super() returns

  constructor(name) {
    super(name);
    console.log(this.species, this.legs);
  }
}

new Dog("Rex");

Output:

dog 4

Gotcha: In a derived class, referencing this (or any subclass field) before calling super() throws a ReferenceError. Always call super() first.

Arrow-function fields for bound methods

A regular method gets its this from how it is called, so passing a method as a callback usually loses the instance binding. Because a class field’s initializer runs with this bound to the new instance, assigning an arrow function to a field captures that this permanently — the method stays bound even when detached.

class Button {
  clicks = 0;

  // Regular method: `this` depends on the call site
  handleLoose() {
    this.clicks += 1;
  }

  // Arrow field: `this` is permanently the instance
  handleBound = () => {
    this.clicks += 1;
  };
}

const btn = new Button();
const loose = btn.handleLoose;
const bound = btn.handleBound;

bound();                 // works — `this` is btn
console.log(btn.clicks); // 1

try {
  loose();               // `this` is undefined in strict mode
} catch (err) {
  console.log(err.constructor.name);
}

Output:

1
TypeError

This pattern is common in event handlers and UI frameworks where methods are passed around as callbacks. The trade-off is shown below.

AspectRegular methodArrow-function field
Lives onThe prototype (shared)Each instance (per object)
this bindingCall-site dependentLocked to the instance
Safe to pass as callbackNo (needs .bind)Yes
Memory per instanceNone extraOne function per field
Overridable by subclassYes (prototype)Only by re-assigning the field

Tip: Prefer regular prototype methods by default; reach for arrow fields only for methods you genuinely pass as detached callbacks. For one-off cases, this.method.bind(this) or wrapping in an arrow at the call site avoids the per-instance cost.

Computed field names

Field names can be computed with square brackets, just like object literals — handy when the key comes from a constant or symbol.

const KEY = "status";

class Job {
  [KEY] = "pending";
  [Symbol.iterator] = function* () {
    yield this.status;
  };
}

const job = new Job();
console.log(job.status, [...job]);

Output:

pending [ 'pending' ]

Best Practices

  • Declare every instance field in the class body so the object’s shape is documented in one place, even fields you set later in the constructor.
  • Use a bare name; (no initializer) to signal a field that the constructor will populate.
  • Remember initializers run per instance and top to bottom — leverage that to derive one field from another, but never rely on them re-running.
  • In derived classes, always call super() before touching this or any subclass field.
  • Default to prototype methods; use arrow-function fields only for methods passed around as callbacks, accepting the per-instance memory cost.
  • For truly hidden state, prefer #private fields over _-prefixed public fields.
Last updated June 1, 2026
Was this helpful?