Compile-time optional chaining for Java, inspired by JavaScript's ?. operator.
Standard javac does not accept ?.. This project adds a two-step Maven pipeline so you can write source that looks like JS optional chaining, then turn it into null-safe bytecode:
opt-chain:preprocess(generate-sources/generate-test-sources) — If a source root contains?., mirror the tree intotarget/opt-chain/sources(tests:target/opt-chain/test-sources), wrap each?.receiver inOptChainOps.opt(...), inject@OptChain(precise = true)when needed, and replace the compile source root sojavacnever sees illegal syntax. The mirror is not undertarget/generated-sources. IntelliJ marks that directory as a source root and would also keepsrc/main/java, so every class (FieldUser,OptChainExamples, …) showed up twice.target/opt-chainstays an ordinary build output and is not an IDE source root. A previoustarget/generated-sources/opt-chaintree is deleted on the next preprocess.opt-chain:weave(process-classes) — Use ASM to null-check only those marked receivers (GETFIELD/ARRAYLENGTH/ instance invokes). A plain.in the same method still throwsNullPointerException(reference →null, primitives → typed zero /false; see Short-circuit defaults).
Comments and string literals are left alone by the preprocessor.
In JavaScript you can write:
user?.address?.city?.nameIf any link is null/undefined, the expression short-circuits instead of throwing. Java forces either nested null checks or Optional plumbing. This project aims for a JS-like authoring experience in Java source (?.), with safety applied at build time rather than only via a runtime helper.
| Module | Role |
|---|---|
opt-chain-annotations |
@OptChain annotation (RetentionPolicy.CLASS) |
opt-chain-maven-plugin |
Goals: preprocess, weave |
opt-chain-demo |
User → Address → City sample with ?., JUnit 5, and a runnable main |
opt-chain-idea |
IntelliJ IDEA plugin (separate Gradle project, 0.2.1-SNAPSHOT): suppress red squiggles, highlight, completion, go-to-declaration, and compile/weave ?. inside IDEA |
- groupId:
io.github.chensirj - version:
0.2.0-SNAPSHOT - Java: 17+
- License: Apache-2.0 (
LICENSE). Publishing steps are in RELEASING.md. The demo module is not published.
The IDEA plugin is not part of the Maven reactor; mvn test only builds the annotation, plugin, and demo modules.
@Target({ElementType.METHOD, ElementType.CONSTRUCTOR})
@Retention(RetentionPolicy.CLASS)
public @interface OptChain {
boolean precise() default false;
}You usually do not write the annotation by hand. When preprocess finds ?. inside a method or constructor, it adds @OptChain(precise = true).
precise = true(what preprocess writes): null-check only receivers wrapped inOptChainOps.opt. If this method's bytecode has no marker — the?.moved into a lambda or another class — the method is left unchanged.precise = false(the default, including a hand-written@OptChainwith no attribute and no markers): legacy mode. Every instance field read and instance call in that method is null-checked, which is what 0.1.x did.
If a method contains OptChainOps.opt calls, those markers win and weaving is precise even when the annotation says precise = false.
- Goal:
opt-chain:preprocess - Phases:
generate-sources(main),generate-test-sources(tests, withprocessTestSources=true) - Output:
target/opt-chain/sourcesandtarget/opt-chain/test-sources(nottarget/generated-sources/opt-chain) - Behavior: outside comments and string/char literals,
a?.bbecomesio.github.chensirj.optchain.OptChainOps.opt(a).b(calls and fields). Methods and constructors whose bodies contain a marker get@OptChain(precise = true)and the import. The generated tree replaces the compile root for the Maven build only. IntelliJ keeps editingsrc/main/java. - Skip:
-Doptchain.preprocess.skip=true - A source root with no
?.is left unchanged (debug log). A read, write, or transform failure names the file path relative to that root.
OptChainOps.opt is an identity method so the generated source compiles. Weave deletes the call. If weave is skipped, the call returns its argument and a later plain dereference still throws.
Example authoring style (demo). Getter chains and field chains are the same rewrite:
public String cityOf(User u) {
return u?.getAddress()?.getCity()?.getName();
}
public String fieldCityName(FieldUser u) {
return u?.address?.city?.name;
}Preprocess does not rely on line and column numbers. It wraps the receiver, which is a real invokestatic OptChainOps.opt in whatever method javac emits. Weave runs an ASM analyzer, remembers values that came from opt, null-checks the GETFIELD / ARRAYLENGTH / instance call that consumes that value (including through checkcast and through arguments that are evaluated first), then removes the opt call.
That survives cases a line-number table cannot name reliably:
| Approach | What goes wrong |
|---|---|
Chosen: OptChainOps.opt(receiver) |
An extra static call exists only until weave. The marker sits in the method that contains the expression, so lambdas, anonymous classes, constructors, and <clinit> keep their own sites. Argument order does not matter: a?.foo(b.foo()) evaluates the inner call first, and only a's call is marked. |
Line + member-name ordinal in @OptChain(sites=...) |
LineNumberTable has no column. The same name on one line is reordered when an argument calls it (a?.foo(b.foo())). One source line can also be split across the enclosing method and a lambda$... method, so a file-wide ordinal does not match either method's local count. |
| Null-check every instance access in the method | This is the 0.1.x behavior. A plain . in an @OptChain method would not throw. It remains available for a hand-written @OptChain with no markers. |
- Goal:
opt-chain:weave - Phase:
process-classes - Precise behavior: a null marked receiver skips that access or call and pushes a typed default. Unmarked reads and calls are unchanged, so a plain
.throwsNullPointerException. - Legacy behavior: a hand-written
@OptChainwith no markers still wraps everyGETFIELD,INVOKEVIRTUAL, andINVOKEINTERFACEin that method. - Skip:
-Doptchain.skip=true - Classes with nothing to rewrite are left unchanged (debug log). The info log reports how many class files were scanned and how many were rewritten. An ASM or I/O failure names the class path relative to the output directory.
INVOKESTATIC is not a ?. site (obj?.staticMethod() is compiled as "evaluate and discard the receiver, then invokestatic", so it does not short-circuit). INVOKEDYNAMIC (string concat, the lambda bootstrap) is not rewritten; the lambda body is a real method and is woven when it contains a marker. Precise mode does rewrite a marked INVOKESPECIAL that is not <init> (a private instance call). Legacy mode still skips INVOKESPECIAL, matching 0.1.x.
Argument expressions are still evaluated in Java order, before the receiver null check. a?.child(bump()) calls bump() even when a is null. JavaScript would skip the arguments.
Each ?. null-checks only its own receiver. The rest of the expression still runs.
| Source | a == null |
|---|---|
a?.b()?.c() |
result is null (or 0 / false); nothing throws |
a?.b().c() |
b() is skipped and yields null, then the plain .c() throws NullPointerException |
a.b() in the same method |
throws NullPointerException |
JavaScript would skip the entire chain in a?.b().c(). This project does not: a plain . means "this receiver must be non-null". Write ?. on every link that should short-circuit. a?.b()?.c() is the form that matches the older all-sites demos.
The same rule applies to fields (u?.address.city throws if address is null) and to several chains on one line (a?.name() + b?.name() short-circuits each chain on its own).
Defaults are fixed (not configurable). A null receiver skips that access or call, and the weaver pushes:
| Result type | Value |
|---|---|
boolean |
false |
byte, char, short, int |
0 (char is '\0') |
long |
0L |
float |
0.0f |
double |
0.0 |
| reference or array | null |
void |
no value; the call is skipped |
opt-chain-demo exercises int, boolean, long, double, and reference results with ?. on User → Address → City (OptChainExamples), and the same shapes on public fields (FieldUser → FieldAddress → FieldCity):
| Method | When the chain is present | When a link is null |
|---|---|---|
nameLength |
String.length() |
int 0 |
primaryAddress |
Address.isPrimary() |
boolean false |
cityId |
City.getId() |
long 0L |
cityLatitude |
City.getLatitude() |
double 0.0 |
city |
that City instance |
null |
cityOf |
the city name | null |
Root null (user == null) and mid-chain null (address or city null) both short-circuit from the missing link. A prefix that is still non-null keeps its real value: primaryAddress is the address flag even when city is null. city is non-null when user, address, and city are all present. A null city name short-circuits nameLength to 0 while city itself stays non-null.
Field reads use that same preprocess rule and the same GETFIELD null-check. Parallel methods on the public-field model:
| Method | Chain | When a link is null |
|---|---|---|
fieldCityName |
u?.address?.city?.name |
null |
fieldNameLength |
…?.name?.length() |
int 0 |
fieldPrimary |
u?.address?.primary |
boolean false |
fieldCityId |
…?.city?.id |
long 0L |
fieldCityLatitude |
…?.city?.latitude |
double 0.0 |
fieldCity |
u?.address?.city |
null |
A null address or city field short-circuits from that link. fieldPrimary stays the real flag when city is null. A null name short-circuits fieldNameLength to 0 while fieldCity stays non-null.
byte, char, short, and float use the same weaver table. The demo model does not call them.
- Depend on the annotation:
<dependency>
<groupId>io.github.chensirj</groupId>
<artifactId>opt-chain-annotations</artifactId>
<version>0.2.0-SNAPSHOT</version>
</dependency>- Bind the plugin (preprocess + weave):
<plugin>
<groupId>io.github.chensirj</groupId>
<artifactId>opt-chain-maven-plugin</artifactId>
<version>0.2.0-SNAPSHOT</version>
<executions>
<execution>
<id>preprocess-main</id>
<goals><goal>preprocess</goal></goals>
<phase>generate-sources</phase>
</execution>
<execution>
<id>weave</id>
<goals><goal>weave</goal></goals>
<phase>process-classes</phase>
</execution>
</executions>
</plugin>- Write
?.in source (demo already does this).
From the repo root. GitHub Actions (.github/workflows/ci.yml) runs the same mvn -B clean test on Java 17 for pushes to main and for pull requests.
# Full reactor tests (annotations, plugin, demo)
mvn -B clean test
# Windows example checkout
mvn -f D:\java-opt-chain\pom.xml clean test
# Demo module only (builds dependencies)
mvn -pl opt-chain-demo -am testmvn test runs opt-chain-demo (woven ?. behavior for getters, public fields, precise vs plain dots, static methods, lambdas, nested classes, constructors, and a hand-written legacy @OptChain) and opt-chain-maven-plugin unit tests (source rewrite, plus an in-memory compile/weave harness). The IDEA plugin is a separate Gradle build.
To build source and javadoc jars without signing or uploading, see RELEASING.md:
mvn -B -P release -Dgpg.skip packageRunnable main (OptChainDemoMain) prints four city/name cases (full chain, null address, null city, null user), the primitive short-circuit defaults, the matching public-field chains, and a precise-site check (plainCity(null) throws, optional chains do not):
# Build/install the reactor first (needed once / after clean), then run main on the demo module only.
# Do not use `-am exec:java` from the root — exec would run on the parent POM and miss mainClass.
mvn -pl opt-chain-demo -am package
mvn -pl opt-chain-demo exec:java
# Windows:
mvn -f D:\java-opt-chain\pom.xml -pl opt-chain-demo -am package
mvn -f D:\java-opt-chain\pom.xml -pl opt-chain-demo exec:javaThe demo pom.xml configures exec-maven-plugin with:
mainClass=io.github.chensirj.optchain.demo.OptChainDemoMain
Alternative after package/test:
java -cp opt-chain-demo/target/classes:opt-chain-annotations/target/classes \
io.github.chensirj.optchain.demo.OptChainDemoMain(On Windows, use ; instead of : in the classpath.)
Two separate IDE problems showed up on Windows (IDEA 2024.1+ / 2025.2):
| Symptom | Cause |
|---|---|
java: 非法的表达式开始 on ?. in OptChainExamples.java |
IDEA's Java compiler does not run Maven opt-chain:preprocess, so ?. is still illegal Java. |
Duplicate class found in target\generated-sources\opt-chain\...\FieldUser.java |
IDEA indexed both src/main/java and the preprocess mirror. |
What landed: the IDE plugin (0.2.1) rewrites ?. to OptChainOps.opt(...) during the IDEA build — the same transform as Maven preprocess — then weaves the class files, so Run/Debug short-circuits nulls. Maven preprocess + weave stay the source of truth for mvn test / mvn exec:java. Authoring sources keep ?.. The mirror moved to target/opt-chain/sources, which IDEA does not mark as a source root. The plugin also excludes that folder (and the old generated-sources/opt-chain path) on Maven import.
From opt-chain-idea (JDK 17):
cd opt-chain-idea
./gradlew buildPlugin
# zip: opt-chain-idea/build/distributions/opt-chain-idea-0.2.1-SNAPSHOT.zip- Settings → Plugins. If an older Opt Chain is installed, uninstall it (the previous id was
io.ch.java.opt.chain.idea) and restart. - Settings → Plugins → ⚙️ → Install Plugin from Disk… → select the zip. Restart.
- File → Invalidate Caches… → Invalidate and Restart.
- Maven tool window → Reload the project. That drops a stale
target/generated-sources/opt-chainsource root. If it is still listed under Project Structure → Modules → opt-chain-demo → Sources, remove it.src/main/javastays the only Java source root for the demo. - Optional once, so the legacy directory is deleted from disk:
mvn -pl opt-chain-demo -am generate-sources(orcompile), then reload Maven again. - Run OptChainDemoMain (the checked-in run configuration, or the gutter icon on
main). The build log should includeOpt Chain compiled opt-chain-demo. Cases 1–11 must not throw;plainCity(null)must throw. OptChainExampleshas nomain. Run it by running OptChainDemoMain, or run the JUnit classOptChainExamplesTest. Building the demo module compilesOptChainExamples.javawith?.still in the editor.
The run configuration is .idea/runConfigurations/OptChainDemoMain.xml (module name opt-chain-demo, Make before launch). If IDEA names the module differently after import, point the configuration at the demo module.
Without the plugin, IDEA still cannot compile ?.. Use Maven instead: mvn -pl opt-chain-demo -am package then mvn -pl opt-chain-demo exec:java.
Independent Gradle project. Version 0.2.1-SNAPSHOT. Maven preprocess + weave remain the source of truth for command-line builds. The plugin compiles ?. inside IDEA by applying that same rewrite and weave before Run/Debug.
cd opt-chain-idea
./gradlew buildPlugin
# zip under opt-chain-idea/build/distributions/Lightweight unit tests (no IDE UI):
./gradlew testThere is no prebuilt zip in the repository. Build one with ./gradlew buildPlugin; the archive is opt-chain-idea/build/distributions/opt-chain-idea-0.2.1-SNAPSHOT.zip (that directory is Gradle output and is not committed). Plugin id is io.github.chensirj.optchain.idea.
- IntelliJ IDEA 2024.1+ (sinceBuild 241); verified path includes 2025.2 (IU-252)
- If an older Opt Chain is installed, uninstall it first. The previous id was
io.ch.java.opt.chain.idea; IDEA treats that as a different plugin, so leaving it installed keeps the old highlighters loaded. - Settings → Plugins → ⚙️ → Install Plugin from Disk… → select the zip from
build/distributions/ - Restart IDE
- If
?.still shows red: File → Invalidate Caches → Invalidate and Restart
| Feature | Mechanism |
|---|---|
Fewer red squiggles on ?. |
HighlightErrorFilter / highlight filters for related PsiErrorElements |
| Highlighting | Annotator + color settings page |
| Completion | Treat ?. as . temporarily, resolve left-hand type, contribute fields and methods (one heuristic) |
| Go to declaration | Same rewrite-then-resolve. If resolve() is empty, fall back to a field (plain obj?.field) or method (call) of the qualifier type by name |
| Compile / Run / Debug | JPS builder rewrites ?. to OptChainOps.opt(receiver) (same as Maven preprocess), compiles with the module JDK, then weaves class files. Roots that contain ?. are excluded from IDEA's own javac so it never sees the token. |
- Not a real Java grammar extension; PSI may still be incomplete
- Completion / navigation are heuristics shared by fields and methods (
?.is not a separate field grammar); Maven compile is authoritative - 0.2.1 compiles
?.inside IDEA (rewrite + weave) and keeps the Maven mirror off the IDE source path. 0.2.0 changed the plugin id and Java package toio.github.chensirj.optchain.idea. Uninstall idio.ch.java.opt.chain.ideabefore installing this build. - Requires the Java plugin (
com.intellij.modules.java) - Eclipse / VS Code are out of scope
Markers travel with the bytecode, so the annotation on the enclosing method is not required for the synthetic method itself:
| Location | Works? |
|---|---|
| Instance methods | yes |
| Static methods | yes |
Lambdas (lambda$... methods) |
yes |
| Anonymous classes and inner / local classes | yes |
| Constructors | yes |
Instance field initializers and instance initializer blocks (copied into each <init>) |
yes |
Static field initializers (<clinit>) |
yes |
new Foo()?.bar optional-checks bar only. new itself never yields null, and <init> is not rewritten.
- Without the IDEA plugin, the editor still flags
?.as illegal syntax and IDEA's compiler rejects it. Maven preprocess is what command-linejavaccompiles. Plugin 0.2.1 performs that preprocess and the weave inside the IDEA build; annotation processing is not run for modules the plugin compiles (-proc:none). - Preprocess is a text scan, not a Java parser.
?.outside line comments, block comments, and string/char literals is rewritten, whether the next token is a field (u?.address?.city?.name) or a call; those spots are covered byOptChainSourceTransformerTest. There is no separate text-block mode, so a"inside a"""block can desync the scan. - The receiver walk understands calls, indexes, parenthesized expressions, generic
name<...>, qualified names, andnew. It can miss a receiver that is not a postfix expression (for example a lambda used as the receiver). - Method detection for inserting
@OptChainis heuristic. Javadoc such as{@code @OptChain}is not treated as the annotation. A missed annotation is still safe when the marker is present: weave keys offOptChainOps.opt, not only the annotation. a?.b().c()does not skip.c()(see the table above).obj?.staticMethod()does not short-circuit.- Call arguments are still evaluated left-to-right before the receiver null check.
- No
??, Elvis operator, or configurable defaults. - Maven Central publishing is prepared (
-P release) but this tree is not uploaded by the default build. See RELEASING.md.
io.github.chensirj.optchain.OptChainio.github.chensirj.optchain.OptChainOpsio.github.chensirj.optchain.plugin.OptChainPreprocessMojoio.github.chensirj.optchain.plugin.OptChainSourceTransformerio.github.chensirj.optchain.plugin.OptChainWeaveMojoio.github.chensirj.optchain.plugin.OptChainWeaverio.github.chensirj.optchain.demo.OptChainExamplesio.github.chensirj.optchain.demo.OptChainPreciseExamplesio.github.chensirj.optchain.demo.OptChainDemoMainio.github.chensirj.optchain.idea.*(IDEA extension points)