Flexible Constructor Bodies: The End of Telescoping Builders? A Preview Feature from Project Amber
Until Java 22 every constructor had to start with exactly one constructor invocation (super(…) or this(…)). That rule guaranteed top-down initialization but forced us into awkward helper methods and telescoping constructors whenever we needed to
- validate parameters before the parent constructor runs,
- perform expensive argument transformations, or
- share a computed object between multiple
super(…)arguments.
JEP 492 tears down that wall by splitting a constructor into two phases — a prologue that runs before the constructor invocation and an epilogue that runs after it. Code in the prologue may not observe the still-unborn object, but it may assign to its fields and execute arbitrary logic.
This new feature was initially introduced as JEP 447 and named as Statements before super(…). After some feedback from the Java Community, this feature has been reintroduced as Flexible Constructor Bodies in Java 23 and Java 24 as preview feature.
{ // prologue
… // validate, compute, assign fields
super(…); // or this(…)
… // epilogue
}Bottom-up, then top-down
At run time the JVM first executes every prologue bottom-up through the class hierarchy, then every epilogue top-down. The object is fully formed only after all parent constructors finish, just as before, so existing safety guarantees hold.
Everyday use-cases (with before/after code)
Fail-fast validation
Before:
public final class AbsolutePath extends File {
private static String verified(String path) {
if (!Paths.get(path).isAbsolute()) {
throw new IllegalArgumentException("Path must be absolute");
}
return path;
}
public AbsolutePath(String path) {
super(verified(path)); // validation buried in a helper
}
}After:
public final class AbsolutePath extends File {
public AbsolutePath(String path) {
// ── Prologue ────────────────────────────────────────────────
if (!Paths.get(path).isAbsolute()) {
throw new IllegalArgumentException("Path must be absolute");
}
// ── Constructor invocation ─────────────────────────────────
super(path);
// ── Epilogue (optional) ────────────────────────────────────
}
}Expensive argument preparation
Before:
public final class Base64SecretKey extends SecretKeySpec {
// Helper stuck here only to satisfy the superclass constructor
private static byte[] decode(String base64) {
return Base64.getDecoder().decode(base64);
}
public Base64SecretKey(String base64, String algorithm) {
super(decode(base64), algorithm); // helper hides the work
}
}
public final class CertKey extends KeySpec { // new style
public CertKey(Certificate cert) {
byte[] raw = switch (cert.getPublicKey()) { … }; // prologue
super(raw);
}
}After:
public final class Base64SecretKey extends SecretKeySpec {
public Base64SecretKey(String base64, String algorithm) {
// ── Prologue ────────────────────────────────────────────────
byte[] keyBytes = Base64.getDecoder().decode(base64); // expensive step
// ── Constructor invocation ─────────────────────────────────
super(keyBytes, algorithm);
// ── Epilogue (optional) ────────────────────────────────────
}
}Sharing objects between
superarguments
Before:
// Superclass needs two identical Dimension objects (row & column size).
class Grid extends Canvas {
Grid(Dimension rows, Dimension cols) { /* … */ }
}
class SquareGrid extends Grid {
// Public ctor delegates to a private helper to compute Dimension once
SquareGrid(int size) {
this(new Dimension(size, size)); // helper ctor call
}
private SquareGrid(Dimension dim) { // helper only to avoid recompute
super(dim, dim); // pass same object twice
}
}After:
class SquareGrid extends Grid {
public SquareGrid(int size) {
// ── Prologue: create the shared Dimension exactly once ─────────
Dimension dim = new Dimension(size, size);
// ── Constructor invocation ─────────────────────────────────────
super(dim, dim); // reuse the same object without another ctor
}
}Defending against “constructor-calls-override” landmines
Superclass calls an overridable method:
Before:
// Library code you can’t change
class Processor {
Processor() { init(); } // calls overridable hook
void init() { } // meant to be overridden
}
// Your subclass
class CsvProcessor extends Processor {
final String delimiter;
CsvProcessor(String delimiter) { // old style
super(); // overridable method runs here
this.delimiter = delimiter; // field still uninitialised during init()
}
@Override void init() {
System.out.println("Delimiter = " + delimiter); // prints: null
}
}Without early field assignment the subclass can witness default values:
After:
class CsvProcessor extends Processor {
final String delimiter;
public CsvProcessor(String delimiter) {
// ── Prologue ────────────────────────────────────────────────
this.delimiter = delimiter; // safe: assign field first
// ── Constructor invocation ─────────────────────────────────
super(); // overridable method now sees valid state
// ── Epilogue (optional) ────────────────────────────────────
}
@Override void init() {
System.out.println("Delimiter = " + delimiter); // prints: ,
}
}Early assignment to fields declared in the same class is allowed, preventing the classic “partially constructed” bug.
Removing telescoping constructors
Before:
// Low-level superclass you can’t change
class Connection {
Connection(String url, Properties props) { /* … */ }
}
// Your subclass
class DatabaseConnection extends Connection {
// Public API: accept a config object
DatabaseConnection(DbConfig cfg) {
// must delegate to another ctor because we can’t touch code before super()
this(cfg.url(), buildProps(cfg));
}
// Helper ctor exists solely to forward already-computed arguments
private DatabaseConnection(String url, Properties props) {
super(url, props);
}
// Static helper for property preparation
private static Properties buildProps(DbConfig cfg) {
Properties p = new Properties();
p.setProperty("user", cfg.user());
p.setProperty("password", cfg.password());
p.setProperty("ssl", String.valueOf(cfg.ssl()));
return p;
}
}After:
class DatabaseConnection extends Connection {
// One constructor does it all
DatabaseConnection(DbConfig cfg) {
// ── Prologue: build and validate once ────────────────────────
String url = cfg.url();
if (!url.startsWith("jdbc:")) {
throw new IllegalArgumentException("URL must start with jdbc:");
}
Properties props = new Properties();
props.setProperty("user", cfg.user());
props.setProperty("password", cfg.password());
props.setProperty("ssl", String.valueOf(cfg.ssl()));
// ── Constructor invocation: delegate exactly once ────────────
super(url, props);
// (optional epilogue goes here)
}
}Cleaner factory patterns in records & enums
Even record non-canonical constructors can now preprocess arguments before the mandatory this(…) call, and enum constructors can prepare state before chaining — no more static helpers.
Impact on Other Project Amber Features
Flexible constructor bodies don’t live in isolation; they slot neatly into several other Amber initiatives.
- They pair brilliantly with Scoped Values and virtual threads: you can validate input and initialize a
ScopedValuein the constructor’s prologue, then pass that value straight down to tasks that run inside aStructuredTaskScope, confident that everything was checked before any child thread saw the object. - They dovetail with primitive-pattern matching (JEP 488). Because the prologue is just ordinary code, you can deconstruct primitive arguments in a
switchright at the top of the constructor, transforming or rejecting values before the call tosuper()—a cleaner, faster alternative to scatteringif/elsechecks across the epilogue. - Finally, the change nudges the language closer to Project Valhalla’s value classes: early, single-assignment of fields is a prerequisite for creating truly immutable, identity-less objects, so having a safe place in the constructor to perform that initialization is an essential stepping-stone toward the “inline” types we’ll likely see in the next few releases.
FAQ Corner
Developers often ask whether dependency-injection frameworks will behave in the prologue. The answer is yes — as long as you reference static members or fields of an enclosing instance; calling methods on the not-yet-constructed this is still off-limits.
Performance is another common concern, but the byte-code emitted for a flexible constructor is virtually identical to today’s constructors, so you pay no runtime penalty for using the feature.
Record types are a special case: their canonical constructors still cannot contain a prologue because there is no explicit this(...) or super(...) call to anchor the split, but non-canonical record constructors benefit fully from the new capability.
Finally, many readers wonder when the preview flag will disappear. Given that the feature has reached its third preview with only minor wording tweaks and no VM work required, the JDK team has signaled a strong intention to make flexible constructor bodies permanent—very likely in Java 25, assuming no late-breaking feedback surfaces.
Happy Learning!
Useful links:
