Frontend / ECMAScript features / 02_class_features.md

Class Features — Private Fields, Static Blocks, Decorators

Updated 4 interview angles 6 min read source
On this page13
  1. #private vs TypeScript private — what’s the difference?
  2. Show me #private features.
  3. #in check — does this object have the private field?
  4. Static initialization blocks — what’s the use?
  5. Decorators — what stage are we at?
  6. Decorator syntax — show me.
  7. Are decorators worth using now?
  8. Auto-accessors — what?
  9. Why use classes at all in modern React/Vue?
  10. Gotchas
  11. Related
  12. Further reading
  13. Interview angle

Class Features — Private Fields, Static Blocks, Decorators

Modern class features added since ES2022: #private fields (real privacy enforced by the engine, not convention), static blocks (static { ... } for one-time init in a class body), and decorators (Stage 3, @decorator syntax for class/method/field meta-programming). All three are now in TC39 Stage 4 or near-final. The big mental shift: #x is real privacy — not the TypeScript private keyword, which only prevents TS errors and is ignored at runtime.

#private vs TypeScript private — what’s the difference?

TS private #private
Layer type-time only runtime
At runtime accessible (obj["private"] works in JS) not accessible (throws / undefined outside class)
In compiled JS gone (just a normal property) #name becomes a WeakMap entry, truly inaccessible
Reflect.has finds it does not find #name
Subclass access yes (in TS) no — #x is class-only
ts
class Cat {
  private name = "Felix";        // TS-private
  #age = 3;                       // truly private
}

const c = new Cat();
// In .ts: c.name → TS error, but at runtime it works (c["name"] returns "Felix")
// c.#age → SyntaxError at parse: "Private field '#age' must be declared in an enclosing class"

For real privacy (other JS code can’t access), use #. TS private is a hint; JS engines don’t enforce it.

Show me #private features.

ts
class BankAccount {
  #balance: number;
  static #nextId = 1;             // private static
  #id: number;

  constructor(balance: number) {
    this.#balance = balance;
    this.#id = BankAccount.#nextId++;
  }

  deposit(n: number) { this.#balance += n; }
  get balance() { return this.#balance; }

  // Private method
  #computeInterest() { return this.#balance * 0.05; }

  applyInterest() { this.#balance += this.#computeInterest(); }
}

All # members: private fields, private methods, private static fields, private static methods, private getters/setters. Real privacy.

#in check — does this object have the private field?

ts
class Animal {
  #name = "x";

  static isAnimal(obj: unknown): obj is Animal {
    return typeof obj === "object" && obj !== null && #name in obj;
  }
}

console.log(Animal.isAnimal(new Animal())); // true
console.log(Animal.isAnimal({}));            // false

#x in obj is the “brand check” — confirms an object is a real instance of the class (not a duck-typed lookalike). Useful for runtime type discrimination.

Static initialization blocks — what’s the use?

Initialize static state that needs control flow (try/catch, loops, async-light setup):

ts
class Config {
  static #data: Record<string, unknown>;

  static {
    try {
      Config.#data = JSON.parse(globalThis.__INITIAL_CONFIG__ ?? "{}");
    } catch (e) {
      console.error("bad config", e);
      Config.#data = {};
    }
    if (!Config.#data.timeout) Config.#data.timeout = 30_000;
  }

  static get(key: string) { return Config.#data[key]; }
}

Runs once when the class is defined. Cleaner than module-level IIFE for class-scoped init.

Multiple static {} blocks are allowed; they run in source order.

Decorators — what stage are we at?

Stage 3 at TC39; specced, implemented by TypeScript 5.0+, supported by SWC and Babel, available in modern Node and bundlers. Different from the legacy TS experimental decorators (experimentalDecorators: true) — the new spec is its own thing.

Decorator syntax — show me.

ts
function logged<T extends new (...args: any[]) => any>(target: T, ctx: ClassDecoratorContext) {
  ctx.addInitializer(function () {
    console.log(`Created ${ctx.name}`);
  });
  return target;
}

function bound(target: Function, ctx: ClassMethodDecoratorContext) {
  return function (this: any, ...args: any[]) {
    return target.apply(this, args);
  };
}

@logged
class Widget {
  @bound
  handleClick() { console.log("clicked"); }
}

Decorators receive:

  • The target (class, method, field, accessor, getter, setter).
  • A context object with kind, name, addInitializer, private flag, etc.

Use cases:

  • Method binding (@bound to auto-bind this).
  • Logging / instrumentation (@deprecated, @logged).
  • Memoization (@memoized).
  • Framework integration (Angular components, MobX @observable, NestJS controllers).

Are decorators worth using now?

Yes for new code in supportive ecosystems (Angular, NestJS, MobX, LitElement). Cautious for general TS apps — debugging and tooling around decorators is still maturing.

The legacy experimental decorators (still common in Angular, NestJS) are different syntax + semantics. Migration to stage-3 decorators is in progress for many frameworks.

If you’re writing a library that exposes decorators: use stage-3 syntax (tsconfig without experimentalDecorators). If you’re maintaining legacy code with @injectable, stay on the old spec until the framework upgrades.

Auto-accessors — what?

A stage-3 sibling of decorators: declare a field as auto-accessor (accessor keyword) so decorators can intercept reads/writes:

ts
function debounced<T>(ms: number) {
  return function (target: ClassAccessorDecoratorTarget<unknown, T>, ctx: ClassAccessorDecoratorContext) {
    let timer: any;
    return {
      get() { return target.get.call(this); },
      set(value: T) {
        clearTimeout(timer);
        timer = setTimeout(() => target.set.call(this, value), ms);
      },
    };
  };
}

class Search {
  @debounced(300) accessor query = "";
}

accessor query = "" desugars to a private field + get/set. Useful for reactive frameworks adopting decorators.

Why use classes at all in modern React/Vue?

Increasingly rare. React abandoned class components for hooks; Vue’s Options API still uses class-like structure but moved to Composition API. Classes still fit for:

  • Domain models (User, Order, Cart) — clearer than nested objects for behavior + state.
  • Service classes (ApiClient, EventBus, Logger) — singleton-style.
  • Error subclasses — class NetworkError extends Error { constructor(...) { ... } }.
  • Framework integration that requires classes (NestJS, Angular).
  • DOM custom elements / web components (class MyElement extends HTMLElement).

For UI components in React: hooks-functional all the way. Vue’s Composition API + <script setup> is also class-less.

Gotchas

  • #x outside the declaring class is a parse error, not a runtime one — can’t be conditionally accessed.
  • #x in obj is the only safe brand check — instanceof can lie across realms (iframes, workers).
  • Subclass access — #x is class-private, not protected. A subclass cannot access parent’s #x. Use TS protected (TS-only) or design via methods.
  • Decorators can’t change the kind of thing — a class decorator returns a class; a method decorator returns a method. The runtime guards against returning the wrong shape.
  • Decorator evaluation order — class decorators run after method decorators; bottom-up within each kind.
  • static {} and await — top-level await doesn’t work inside a static block. Use an async IIFE if needed, or set up the static differently.
  • Compile target matters — target: "ES2022" for native #x; older targets transpile to WeakMap (still private but with overhead).

Further reading

Interview angle 4

  • “How is #private different from TypeScript’s private?” - #x is enforced by the engine at runtime; TypeScript’s private is erased at compile time and the property is plainly accessible from JavaScript. Use # whenever other code must genuinely not reach the value, such as a key or a token.
    • Follow-up: “Is there a use for # beyond privacy?” - the brand check. #x in obj is true only for real instances of that class, which works across realms and is not fooled by a duck-typed mock. It is the reliable “is this really one of mine” test.
  • “What are static blocks for?” - one-time initialisation that needs control flow and access to the class’s private state. Before them you wrote a module-level IIFE after the class, which could not see # members and sat awkwardly apart from the thing it initialised.
  • “Are decorators usable yet?” - yes. The Stage 3 proposal is implemented in TypeScript 5.0+ and modern bundlers, and its semantics differ from the legacy experimentalDecorators form. The two are not interchangeable, so migrate when the framework you depend on does.
  • “When would you reach for a class at all?” - domain models, services with dependencies, Error subclasses, web components, and frameworks built around them like NestJS or Angular. Most React and Vue UI code has no use for them, and reaching for a class there usually means recreating what hooks already do.