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.

@logdecorator fnvaluewhat’s decoratedcontextkind, name, …replacementor nothingcalled once, when the class is being defined —not on every call, not at runtime later
Every decorator is a function called with (value, context) at class-definition time. It may return a replacement or return nothing.

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.

Stage 0–1ideaStage 2draftStage 3DECORATORSneeds transpilerStage 4in the specstatic { }Baselinemore mature →
The TC39 stage ladder. Decorators sit at Stage 3: design-frozen and shipping in build tools, but not yet native in any JS engine.

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.

callwrapper (returned by @log)console.log( “→ add(…)” )original add(a, b) — value.apply(this, args)console.log( “← add returned 5” )result
A method decorator replaces the original function with a wrapper. Calls flow into the wrapper, which runs before-logic, delegates to the original, then runs after-logic.

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.

interactiveWhat @log compiles to — wrap and reassign by hand

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.

kind
which of the six things this is
name
string or Symbol key
static / private
booleans
access.get / set
read/write the live value later
addInitializer(fn)
run fn while each instance is constructed
The context object tells a decorator what it is decorating and gives it hooks (access, addInitializer) into the instance being built.

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

@double count = 10field declarationinitializer(initial)runs per instancereturn initial * 2count === 20stored value10 → initializer → 20 (repeats for every new Box())
A field decorator returns an initializer. The initializer runs during each instance's construction, receiving the written value and returning the stored value.

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.

@log add() { }your source.ts / .js + @TypeScript 5+or Babeladd = log(add, ctx)plain function callengine runs thisno @ survives to the engine
Decorator source never reaches the engine as-is. TypeScript or Babel rewrites @ syntax into ordinary function calls; the browser only sees plain JavaScript.

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.

class Config { … }static { … }runs ONCE, herethis === Configat definition timenew Config()new Config()new Config()block does NOT re-run
A static block runs a single time, while the class is being defined, with `this` bound to the class. Instances created later never re-trigger it.

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.

interactiveA static block runs once, at definition time

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.

Static fields and blocks run top-to-bottom
1/4
Variables
— nothing yet —
Console
field a
First static field initializer runs.

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.

interactiveSharing a #private field through a static block

Rules and gotchas

A few sharp edges worth memorizing:

  • No await, no yield. 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 var inside a static { } does not leak out, and does not merge with an outer var of the same name — a real difference from var elsewhere.
  • Parent runs first. In an inheritance chain, the superclass’s static blocks and fields finish before the subclass’s begin.
  • No arguments, no super() 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.

@decorators
Stage 3 · needs TypeScript 5+/Babel · no native engine yet · (value, context)
static { }
ES2022 · Baseline since 2023 · runs everywhere modern · zero tooling
Two features, two very different maturity levels — pick your dependencies accordingly.

Summary

  • A decorator is a function attached with @name above 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.kind tells 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; accessor returns { 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 experimentalDecorators design (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, with this bound 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 #private members, letting you share private state with same-scope code on purpose.