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 |
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.
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?
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):
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.
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,privateflag, etc.
Use cases:
- Method binding (
@boundto auto-bindthis). - 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:
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
#xoutside the declaring class is a parse error, not a runtime one — can’t be conditionally accessed.#x in objis the only safe brand check —instanceofcan lie across realms (iframes, workers).- Subclass access —
#xis class-private, not protected. A subclass cannot access parent’s#x. Use TSprotected(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 {}andawait— top-levelawaitdoesn’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 toWeakMap(still private but with overhead).
Related
Further reading
- MDN — Private class features: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_properties
- MDN —
staticinitialization blocks: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Static_initialization_blocks - TC39 — Decorators: https://github.com/tc39/proposal-decorators
- TypeScript 5.0 — Decorators: https://devblogs.microsoft.com/typescript/announcing-typescript-5-0/#decorators
Interview angle 4
- “How is
#privatedifferent from TypeScript’sprivate?” -#xis enforced by the engine at runtime; TypeScript’sprivateis 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 objistrueonly 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.
- Follow-up: “Is there a use for
- “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
experimentalDecoratorsform. 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,
Errorsubclasses, 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.