How static { } solves complex, interdependent static setup logic cleanly
Since ES2022, static class blocks allow real, multi step initialization logic for static class fields, including access to private fields and regular error handling. This article shows why the feature became necessary, how the syntax works in detail, and where it makes a practical difference.
Table of Contents
- 1. What static class blocks are and why they exist
- 2. The problem: interdependent static fields
- 3. Syntax and a basic example
- 4. Multiple static blocks and execution order
- 5. Accessing private static fields: the friend pattern
- 6. Error handling in static blocks
- 7. Comparison with IIFE and module level alternatives
- 8. Static blocks and inheritance
- 9. Practical use cases and recommendation
- 10. Summary
- 11. FAQ
1. What static class blocks are and why they exist
A static class block is a code block introduced with the static keyword directly inside a class body, which runs exactly once when the class itself is defined, not when an instance is created. Inside the block, this refers to the class, which means static fields, including private ones, are directly accessible. Syntactically the block sits alongside field declarations and methods and can be placed anywhere in the class body.
Before ES2022 there was no way to run multi step logic for static fields without breaking encapsulation. Anyone who needed several static fields whose values depended on each other or came from a more complex computation had to either call a separate init method manually after the class definition or use an IIFE outside the class that set the fields from the outside. Both paths were error prone and meant fields could not really stay private.
2. The problem: interdependent static fields
A typical scenario: a class needs several static fields whose values come from parsing a configuration, building a lookup table from an array, or a multi step computation. Simple field initializers like static x = computeX() can call functions, but each field is evaluated in isolation. There is no shared scope for intermediate values, and a single expression cannot contain a try/catch if the computation might fail.
Before static blocks this was often solved with a static init method that had to be called explicitly after the class definition, which could easily be forgotten and left the class half initialized. Alternatively a module level IIFE set the static fields from the outside, which meant those fields could no longer be declared as private for encapsulation reasons and effectively became part of the public API.
3. Syntax and a basic example
A static block is simply written with the static keyword followed by a curly brace block. Inside this block, this is the class itself, so private static fields, recognizable by the hash prefix, can be assigned directly. The following example shows a class that reads two base values from an external configuration and computes a third, combined private field from them.
Importantly, the block has a completely normal statement block scope. Local variables declared with let or const inside the block act as intermediate storage for the computation and stop existing once the block finishes running. Only the class fields that are actually assigned remain as the result, which makes the block a clean, self contained initialization step.
class ReportGenerator {
static #baseUrl;
static #apiVersion;
static #endpoint;
static {
const config = globalThis.APP_CONFIG ?? {};
this.#baseUrl = config.baseUrl ?? "https://api.example.com";
this.#apiVersion = config.apiVersion ?? "v2";
// intermediate value, only visible inside the block
const trimmedBase = this.#baseUrl.replace(/\/$/, "");
this.#endpoint = `${trimmedBase}/${this.#apiVersion}/reports`;
}
static getEndpoint() {
return this.#endpoint;
}
}
console.log(ReportGenerator.getEndpoint());
// -> https://api.example.com/v2/reports
4. Multiple static blocks and execution order
A class can contain more than one static block. All static blocks, together with static field initializers, run strictly in the order they appear in the source code, top to bottom. The same applies to static fields without their own block that sit between two static blocks, which are then initialized at exactly that point in the sequence.
In practice, multiple blocks are used to keep thematically separate initialization steps readable, for example one block for validating the runtime environment and a second for building internal caches. This significantly improves readability compared to a single monolithic block that mixes several unrelated responsibilities and becomes harder to follow.
class FeatureRegistry {
static #flags = new Map();
static #environment;
static {
// Block 1: determine the environment
this.#environment = typeof window === "undefined" ? "server" : "browser";
}
static {
// Block 2: set default flags depending on environment
if (this.#environment === "server") {
this.#flags.set("streaming", true);
} else {
this.#flags.set("streaming", false);
}
}
static isEnabled(name) {
return this.#flags.get(name) ?? false;
}
}
5. Accessing private static fields: the friend pattern
An advanced pattern uses a static block to give an external function controlled access to otherwise private fields, without making them fully public. A block registers an accessor function in a module wide WeakMap or closure variable, which can then be called by selected parts of the module while the rest of the application still has no access at all.
This is not a native friend class concept like in C++, but a pattern built entirely on closures and the one time execution moment of the static block. Precisely because the block runs exactly once when the class loads, it is the ideal place to set up such a controlled bridge, without it being accidentally rebuilt or manipulated elsewhere in the code.
let readInternalState;
class Counter {
#value = 0;
static {
// controlled access for selected code, without making #value public
readInternalState = (instance) => instance.#value;
}
increment() {
this.#value++;
}
}
const counter = new Counter();
counter.increment();
counter.increment();
console.log(readInternalState(counter)); // -> 2
6. Error handling in static blocks
Inside a static block, try/catch works exactly as in any other function. This allows setting fallback values when, for example, an expected configuration file is missing or a JSON.parse call fails, without affecting the entire class definition. This kind of robust, multi step initialization simply was not possible in a single field initializer expression before static blocks.
It is important to know that if an exception leaves the static block unhandled, the entire class definition fails. The module exporting the class then cannot be loaded successfully at all, which can be problematic for optional features. Every potentially failing operation in a static block should therefore be deliberately guarded with try/catch, unless a failure is meant to make the class completely unusable.
class LocaleData {
static #messages;
static {
try {
const raw = globalThis.__LOCALE_JSON__ ?? "{}";
this.#messages = JSON.parse(raw);
} catch (error) {
console.warn("Invalid locale data, using fallback:", error.message);
this.#messages = { hello: "Hello" };
}
}
static translate(key) {
return this.#messages[key] ?? key;
}
}
7. Comparison with IIFE and module level alternatives
Historically two alternatives were used: an IIFE outside the class that then set fields from the outside and thereby broke encapsulation, or a static getter with lazy computation whose result was cached in a variable outside the class. Both approaches work, but they move state and logic out of the class into the surrounding module scope.
The static block is the only approach that combines real encapsulation, meaning access to genuinely private fields, with multi step imperative logic and a clearly defined execution moment. No additional module scope state is needed, and all initialization logic stays visible and traceable directly inside the class body, where it belongs conceptually.
8. Static blocks and inheritance
Every class in an inheritance chain has its own static blocks, which run exactly once when that particular class is defined. Since a derived class can only be evaluated after its base class has been fully defined, the base class static blocks always run first, before the subclass static blocks get their turn.
Inside a static block, super refers to the static side of the parent class. This lets a subclass access inherited static methods and fields of the parent class in its own static block and compute its own specialized static fields on top of them, without having to duplicate the base class's initialization logic.
9. Practical use cases and recommendation
In practice, static blocks are especially well suited for the registry pattern, where a class registers itself with a central registry when it loads, for the one time parsing of environment variables or configuration at class load time, and for building lookup tables from large arrays that would otherwise have to be recomputed on every method call.
As a recommendation: static blocks should only be used when multi step logic, intermediate values, or error handling are actually needed. For simple cases, a plain static x = value stays clearer and easier to read. Overusing static blocks for trivial assignments can make class definitions unnecessarily hard to follow and should be avoided.
class PluginRegistry {
static #plugins = new Map();
static register(name, plugin) {
this.#plugins.set(name, plugin);
}
static get(name) {
return this.#plugins.get(name);
}
}
class LoggerPlugin {
static {
// self registration on module load, no manual call needed
PluginRegistry.register("logger", LoggerPlugin);
}
static describe() {
return "Logger plugin";
}
}
console.log(PluginRegistry.get("logger").describe());
| Approach | Multi Step Logic | Access to Private Fields | Error Handling |
|---|---|---|---|
| Static Field Initializer | No, a single expression | Only its own field | Not possible |
| Static Block | Yes, full block scope | Yes, via this | try/catch possible |
| Constructor | Yes, but per instance | Yes, via this | try/catch possible |
| External init Method | Yes | Only via a public setter | try/catch possible |
| Module Level IIFE | Yes | No, fields must be public | try/catch possible |
Mironsoft
Modern browser APIs, performance, and maintainable JavaScript
JavaScript that holds up in the real browser, not just in the tutorial?
We review existing frontend code for outdated patterns, unnecessary dependencies, and performance traps, then replace them with modern, native browser APIs that mean less bundle weight and less maintenance burden.
Code Review
Systematically finding outdated patterns, unnecessary dependencies, and memory leaks.
Performance Optimization
Improving bundle size, load time, and runtime performance with modern APIs.
Modernization
Deliberately introducing native browser APIs instead of heavy libraries.
10. Summary
Static Class Blocks: The Essentials at a Glance
Syntax
static { } directly in the class body, this refers to the class itself
Execution
Once at class definition time, in source order together with field initializers
Scope
Regular block scope with let/const, try/catch usable normally
Use Case
Registry pattern, configuration parsing, multi step static computations