frontend / es features / 02_class_features.md

Class Features — Private Fields, Static Blocks, Decorators

6 min read source

Class Features — Private Fields, Static Blocks, Decorators

TL;DR

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.

Interview Q&A

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

A:

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
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.

Q: Show me #private features.

A:

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.

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

A:

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.

Q: Static initialization blocks — what’s the use?

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

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.

Q: Decorators — what stage are we at?

A: 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.

Q: Decorator syntax — show me.

A:

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).

Q: Are decorators worth using now?

A: 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.

Q: Auto-accessors — what?

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

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.

Q: Why use classes at all in modern React/Vue?

A: 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 subclassesclass 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 / edge cases

  • #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 checkinstanceof 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 matterstarget: "ES2022" for native #x; older targets transpile to WeakMap (still private but with overhead).

What a senior is expected to say

  • #x is engine-enforced privacy; TS private is type-time only. Use # when other JS code must not access — like security-relevant state.”
  • #x in obj is the brand check pattern — confirms an instance even across realms or inside duck-typed mocks.”
  • “Static blocks for one-time class-scoped init with control flow. Cleaner than module-level IIFE for class-bound state.”
  • “Stage 3 decorators are real now — TS 5.0+, modern bundlers. Different syntax than legacy experimentalDecorators; migrate when frameworks do.”
  • “Most React/Vue UI doesn’t use classes. Classes for domain models, services, error subclasses, web components, NestJS/Angular.”

Cross-references

Further reading