Decorators & Static Initialization Blocks
You have a method that does one job. Now you want it to also log every call, cache its result, or register itself in some table — without editing the method body, and without repeating that plumbing across a dozen other methods. That is the itch decorators scratch: attach behavior to a class member by writing one line above it.
Two features in this article sit at very different points on the maturity curve, and it matters which is which:
- Decorators are a Stage 3 proposal. The syntax and semantics are stable enough that TypeScript 5+ and Babel ship them, but no JavaScript engine runs them natively yet. You need a build step.
- Static initialization blocks — the
static { }block — are part of ES2022 and have been Baseline across every major browser since early 2023. You can ship them today, no tooling required.
We will do decorators first (the flashy one with caveats), then static blocks (the boring one that just works).
The shape of a decorator
A decorator is a function you attach with @name directly above a class, method, field, accessor, getter, or setter. The runtime (or, today, the transpiler) calls that function while the class is being defined and hands it two arguments.
function log(value, context) {
// value → the thing being decorated (a method, a class, ...)
// context → metadata: what kind, its name, is it static/private, ...
// return → a replacement, or nothing to leave it as-is
}
Every decorator, whatever it decorates, receives the same two-argument shape. What value is and what you are allowed to return depend on the context.kind.
Where it stands in the process
The stage ladder tells you how finished a proposal is. Stage 4 means it is in the spec and shipping in engines. Decorators are one rung short, at Stage 3: the design is locked, implementers are encouraged to build it, and the tooling has. The gap is that browsers and Node have not shipped a native implementation, so the @ syntax is meaningless to a raw engine — a transpiler rewrites it into plain function calls before your code ever runs.
Decorating a method
The most common case. A method decorator receives the method function as value and returns a new function to take its place. Here is a logger that wraps any method to print its arguments and result.
function log(value, context) {
if (context.kind !== "method") return value;
return function (...args) {
console.log(`→ ${String(context.name)}(${args.join(", ")})`);
const result = value.apply(this, args);
console.log(`← ${String(context.name)} returned ${result}`);
return result;
};
}
class Calculator {
@log
add(a, b) {
return a + b;
}
}
new Calculator().add(2, 3);
// → add(2, 3)
// ← add returned 5
The returned function replaces add on the prototype. Note the value.apply(this, args) — the wrapper is called as a method, so this is the instance, and we forward it. If you wrote value(...args) instead, you would lose this and any method touching instance state would break.
This is the same idea as wrapping a function by hand — the decorator is just syntax that does the wrap-and-reassign for you at definition time. Since no engine runs @ natively, the demo below does that wrap by hand: it reassigns Calculator.prototype.add to a logging wrapper, exactly what a transpiled @log produces. Change the numbers and watch the before/after lines appear.
The context object
context is where the decorator learns what it is looking at. The common fields:
function inspect(value, context) {
context.kind; // "class" | "method" | "getter" | "setter"
// | "field" | "accessor"
context.name; // "add" (a string, or a Symbol)
context.static; // true if a static member
context.private; // true if a #private member
context.access; // { get, set } — read/write the final value
context.addInitializer; // register a callback run during construction
return value;
}
context.name can be a Symbol for symbol-keyed members, which is why the logger above wrapped it in String(...) before interpolating.
What each kind can return
This trips people up, so keep it close. The rule is: you return a replacement of the same species, or nothing.
context.kind |
value is |
you may return |
|---|---|---|
method |
the function | a new function |
getter / setter |
the accessor fn | a new accessor fn |
field |
undefined |
an initializer (initial) => newValue |
accessor |
{ get, set } |
{ get, set, init } |
class |
the class | a new class / callable |
Two of these are surprising, so let’s slow down on them.
Field decorators get undefined
A field has no value yet when the class is defined — its initializer runs per instance. So a field decorator’s value is undefined, and instead of returning a value you return a function that transforms the initial value on each construction.
function double(value, context) {
// value is undefined here; return an initializer instead
return function (initial) {
return initial * 2;
};
}
class Box {
@double count = 10;
}
new Box().count; // 20
The returned function runs once per instance, receiving whatever the field was set to (10) and returning what it should actually hold (20).
accessor is a new keyword
The proposal adds an accessor keyword that declares an auto-accessor — a private backing field with a generated getter and setter. Its decorator receives { get, set } and can return replacements plus an init to transform the starting value. This is the clean way to intercept both reads and writes.
function observed(value, context) {
return {
get() {
return value.get.call(this);
},
set(newVal) {
console.log(`${String(context.name)} = ${newVal}`);
value.set.call(this, newVal);
},
};
}
class Profile {
@observed accessor name = "Ada";
}
const p = new Profile();
p.name = "Grace"; // logs: name = Grace
p.name; // "Grace"
Decorating the whole class
A class decorator receives the class itself and can return a replacement — often a subclass or a Proxy. A common, honest use is registration: record the class somewhere as a side effect and return it unchanged.
const registry = new Map();
function register(id) {
return function (value, context) {
// a decorator with arguments → a factory that returns the decorator
registry.set(id, value);
return value; // hand the class back untouched
};
}
@register("user")
class UserModel {}
@register("post")
class PostModel {}
registry.get("user"); // class UserModel
Note the pattern: @register("user") is a decorator factory. Because you passed arguments, register is called first and must return the actual (value, context) decorator. This is how every configurable decorator works.
A memoize decorator, end to end
Putting the method pattern to real use. This caches results by first argument so repeated calls skip the work.
function memoize(value, context) {
const cache = new Map();
return function (...args) {
const key = args[0];
if (cache.has(key)) return cache.get(key);
const result = value.apply(this, args);
cache.set(key, result);
return result;
};
}
class Math2 {
@memoize
fib(n) {
return n < 2 ? n : this.fib(n - 1) + this.fib(n - 2);
}
}
const m = new Math2();
m.fib(40); // computed once per n, then cached
The cache lives in the decorator’s closure, created once when the class is defined — so all instances of Math2 share it here. If you need a per-instance cache, create the Map inside an addInitializer and store it on the instance instead. That distinction — closure state is shared, instance state is not — is the most common decorator bug.
The build step is not optional
Since no engine runs decorators natively, your source is rewritten before it executes. Conceptually, @log add() {} becomes something like add = log(add, { kind: "method", name: "add", ... }) — the @ disappears entirely.
To enable them: in TypeScript 5+, the standard decorators work with no flag — just do not set experimentalDecorators, since that switches you to the old design. In Babel, use @babel/plugin-proposal-decorators with { version: "2023-05" }. Deno and Bun compile them through their built-in TypeScript.
Static initialization blocks
Now the feature you can use without apology. Sometimes a class needs setup that a single static x = ... initializer can’t express: a value that depends on several fields, a try/catch, a loop, or writing to a private static field from shared scope. The static { } block gives you a body of statements that runs once, at class-definition time, with this bound to the class.
class Config {
static settings = {};
static {
const raw = readEnv(); // any statements you like
this.settings = JSON.parse(raw); // `this` is the class itself
}
}
It runs exactly once, when the engine evaluates the class — not per instance, and not lazily. Think of it as a constructor for the class object rather than for instances.
Unlike decorators, static { } runs natively in every modern engine — including the one rendering this demo. The counter below is bumped inside the block. Spawn as many instances as you like: the block already ran once at class-definition time and never fires again.
Order matters: top to bottom
A class can hold any number of static { } blocks. They interleave with static field initializers and run strictly in source order. A block can read fields declared above it but not below.
The “above but not below” rule bites hardest with private static fields: reading one before its initializer has run throws a TypeError, even though the field is declared later in the class. Order your blocks accordingly.
The killer feature: private access from shared scope
Because a static block executes inside the class body, it can touch #private members. That lets you hand a controlled capability to code in the same module — the JavaScript equivalent of a C++ friend.
let readSecret;
class Vault {
#secret = 42;
static {
// expose a private reader to the surrounding scope, deliberately
readSecret = (instance) => instance.#secret;
}
}
readSecret(new Vault()); // 42
Without the static block there is no legal place to write instance.#secret outside the class. The block is the escape hatch, and because you wrote it inside the class, encapsulation is still your decision, not an accident.
Try it live. The readSecret function is handed out from inside the class body, so it alone can reach #secret on any Vault. Type a secret, lock it in, then read it back through that exported capability.
Rules and gotchas
A few sharp edges worth memorizing:
- No
await, noyield. The block is synchronous. If setup is async, kick off a promise and store it, but the block itself can’t wait. - Declarations are block-scoped. A
varinside astatic { }does not leak out, and does not merge with an outervarof the same name — a real difference fromvarelsewhere. - Parent runs first. In an inheritance chain, the superclass’s static blocks and fields finish before the subclass’s begin.
- No
arguments, nosuper()call. It is not a function body.
class A {
static order = [];
static { A.order.push("A block"); }
}
class B extends A {
static { A.order.push("B block"); }
}
A.order; // ["A block", "B block"] — parent initialized first
Support
Static blocks reached Baseline in early 2023 and run natively in Chrome/Edge 94+, Firefox 93+, Safari 16.4+, and Node 16.11+. No transpiler, no flag. This is the sharp contrast with decorators: one is a shipping language feature, the other is a proposal riding on your build tooling.
Summary
- A decorator is a function attached with
@nameabove a class member. It is called with(value, context)at class-definition time and returns a replacement or nothing. - The proposal is Stage 3: syntax is stable and shipping in TypeScript 5+ and Babel, but no JavaScript engine runs it natively as of mid-2026 — a build step is mandatory.
context.kindtells you what you are decorating (class,method,getter,setter,field,accessor) and dictates what you may return. Methods return a function; fields return an initializer(initial) => value;accessorreturns{ get, set, init }.- Use
context.addInitializer(fn)for per-instance work; the decorator body itself runs only once. A decorator that takes arguments is a factory that returns the real decorator. - The old
experimentalDecoratorsdesign (target, key, descriptor) is a different, frozen API — don’t mix them. - Static initialization blocks (
static { }) are ES2022 and Baseline since 2023 — no tooling needed. They run once, at class definition, withthisbound to the class. - Multiple blocks and static fields run top-to-bottom; a block can read only what’s declared above it. Blocks can access
#privatemembers, letting you share private state with same-scope code on purpose.