🧩

JSpecify 1.0解説:JavaのNull安全性とIntelliJ IDEAの挙動

に公開

この記事はjig.jp Engineers' Blog Advent Calendar 2025 12月19日分の記事です。


JSpecifyとは?

公式サイトより

複数の組織が協力して、JVM言語で使用される共通の注釈タイプの定義に取り組んでいます。静的解析と言語の相互運用性を向上させるために。私たちの最初の焦点はヌルネス解析です。[1]

とあるように、Javaのステークホルダーで合意を取りながら静的解析の標準化を進めるためのプロジェクトで、Googleが主導[2]しています。
コミュニティに参加している企業・コミュニティのイメージ
トップページより[3]

Javaエンジニアとしてぬるぽこと「java.lang.NullPointerException」は恐れているものであり、常に気を張る要素だと思っています。Null安全性に関して、JavaのコミュニティやOptionalの導入などで様々な模索がされましたが、いよいよNull安全の仕上げがJSpecifyで行われるかもしれません。

JavaのNullの歴史とJSpecifyの立ち位置については、先日のJJUG CCC 2025 Fallにて発表された ウェルスナビさんの「nullの10億ドルの過ちから未来へ Spring Boot 4とJSpecifyが描くnull安全設計」が詳しいので、ぜひそちらもご参照ください。

Version1.0が登場

最初の安定版であり、「公式に、後方的に互換性のない変更を加えることは決してない[4]」とされているVersion1.0が2024年7月にリリースされました。

アノテーションの内容

さっそく、JavaDocを確認してみましょう!

https://jspecify.dev/docs/api/org/jspecify/annotations/package-summary.html

JSpecifyのJavaDocのクラス一覧の部分
「NonNull」「Nullable」「NullMarked」「NullUnmarked」の4つだけ!シンプルですね。

  • すなわち、null安全に関してはJSpecifyに移行し、JSpecifyに存在しない契約(例:@Contract や @Range など)については、引き続きorg.jetbrains.annotationsなど既存のアノテーションを使い続けることになります。

JSpecifyの現在状況

IntelliJ IDEAにおいては、2025.2にて「JSpecify の採用を支援するため、IntelliJ IDEA で JSpecify のアノテーションを JetBrains 独自のアノテーションと同様に第一級オブジェクトとして扱うようにしました。[5]」となっており、2025.3では「IntelliJ IDEAは、2025.3以降、JSpecifyを優先的なnull-safeライブラリとして使用します。」(下記のポスト)ということで、JetBrainsもJSpecifyのデファクトスタンダードに向けた強力なサポートをしています。

また、「Spring Boot 4、Spring Framework 7、Springファミリーの多くの関連ライブラリ、JUnit 6、Guava 33.4+はすでにJSpecifyアノテーションを搭載している[6]」とされており、主要ライブラリにも浸透してきています。

他の観点としては、Kotlin側でJSpecifyを認識しnull型推論に反映される[7][8]ということもあり、JSpecifyの資料などを調べているとKotlinというワードが入っていたり、Kotlinのイベントだったりすることも多い印象です。

JSpecifyの使い方

導入方法

導入方法は他のライブラリと特に違いはありません。MavenでJSpecifyと検索しヒットしたもので「v1.0.0」を選択し、お使いのライブラリに合わせてインポートしていただければと思います。

例:Gradle(kotlin)

// build.gradle.kts
// 省略

dependencies {
    // https://mvnrepository.com/artifact/org.jspecify/jspecify
    implementation("org.jspecify:jspecify:1.0.0")
}

// 省略

アノテーションの種類

@NonNull

JetBrains Annotationsだと「@NotNull」と同等のものとなります。その場所にはnullが入らないことを示します。ただし、後述する@NullMarkedにより、使う場面が少ないかもしれません。
また、次の@Nullableと合わせて、TYPE_USEのアノテーションのため型使用位置に付与可能です。

@Nullable

null を受け入れることを明示するために付けるものです。アノテーションを付与しない場合と比較して、明示化や警告のサポートを受けることができます。

@NullMarked

JSpecifyで目玉のアノテーションだと思います。「パッケージ」「クラス」「メソッド」などに付与すると、その配下のクラスをすべてNonNull扱いすることができます。ただし、パッケージに付与した場合はサブパッケージには適用されません。また、@NullMarkedは@Nullableを無効化しません。使い手の視点では、デフォルトが non-null 側になるため、null を許容したいところだけ @Nullable を付ける形になるということです。チマチマと@NonNullを付けるというのを卒業できます🎉

@NullUnmarked

DB用のエンティティモデルなど、@NullMarkedが不要な場面があると思います。@NullUnmarked は、@NullMarkedによって付与された “未注釈型を non-null と扱う” というデフォルトの解釈を解除し、そのスコープ内の未注釈型を 「null かもしれないかどうかが不明(unspecified nullness)」 とみなすようにします。言い換えると、@NullMarkedの影響を打ち消して、未注釈部分には明示的に@Nullableか@NonNullを付けない限り nullness が指定されない状態に戻します。

コード例

パッケージスコープ(@NullMarked を package-info に置く)

仮に対象をcom.example.apiというパッケージを対象としたときに、その直下にpackage-info.javaファイルを作成し、そこでNullMarkedを付与します。

//package-info.java

/**
 * このパッケージのすべてのクラスはnull-markedです。
 * アノテーションなしの型は非nullableとして扱われます。
 */
@NullMarked
package com.example.api;

import org.jspecify.annotations.NullMarked;

これで、このパッケージにおいて、指定のない場所は@NonNullと同等の挙動となります。たとえば、以下のようなコードがあるとすると、

package com.example.api;

import com.example.model.User;
import org.jspecify.annotations.Nullable;

public interface UserRepository {
    User findById(String userId);

    @Nullable User findByIdOptional(String userId);

    void updateEmail(String userId, String email);
}
package com.example.api;

import com.example.model.User;
import org.jspecify.annotations.Nullable;

import java.util.*;

// 簡易的な実装コード
public final class InMemoryUserRepository implements UserRepository {
    private final Map<String, User> byId = new HashMap<>();

    public InMemoryUserRepository() {
        byId.put("1", new User("1", "Alice", null));
        byId.put("2", new User("2", "Bob", "J"));
    }

    @Override
    public User findById(String userId) {
        User user = byId.get(userId);
        if (user == null) {
            throw new IllegalArgumentException("not found: " + userId);
        }
        return user;
    }

    @Override
    public @Nullable User findByIdOptional(String userId) {
        return byId.get(userId);
    }

    @Override
    public void updateEmail(String userId, String email) {
        User user = findById(userId);
        user.setEmail(email);
    }
}
package com.example.api;

import com.example.model.User;
import org.jspecify.annotations.Nullable;

public final class UserService {
    private final UserRepository repository;

    public UserService(UserRepository repository) {
        this.repository = repository;
    }

    public User findUser(String userId) {
        return repository.findById(userId);
    }

    public @Nullable User findUserOrNull(String userId) {
        return repository.findByIdOptional(userId);
    }

    public void updateEmail(String userId, @Nullable String email) {
        if (email != null) {
            repository.updateEmail(userId, email);
        }
    }
}

このとき、UserServiceで

  • findUser関数で、不要なnullチェックを追加
  • findUserOrNull関数がNullable関数をreturnするのに、@Nullableを外す
    と、

このように、明示的に@NonNullの記述をしていないのに、@NonNullと評価されたうえで、警告が出ることを確認できます。

他の確認として、パッケージ外で定義した関数を使用する状況で、Nullableな内容をNotNullの場所に入れるときでも、このように警告が出ます。

package com.example;

import com.example.api.InMemoryUserRepository;
import com.example.api.UserService;
import org.jetbrains.annotations.Contract;
import org.jspecify.annotations.*;

import java.util.Objects;

public final class DemoMain {
    static void main() {
        UserService userService = new UserService(new InMemoryUserRepository());
        userService.findUser(nullkun("null"));

    }

    @Contract(pure = true)
    public static @Nullable String nullkun(@NonNull String str){
        if(Objects.equals(str, "null")){
            return null;
        }
        return str;
    }
}

クラススコープ

次は、クラスに対して@NullMarkedを付与してみます。

package com.example.model;

import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

@NullMarked
public final class User {
    private final String id;
    private final String name;

    private final @Nullable String middleName;
    private @Nullable String phoneNumber;
    private @Nullable String email;

    public User(String id, String name, @Nullable String middleName) {
        this.id = id;
        this.name = name;
        this.middleName = middleName;
    }
    

    public @Nullable String getMiddleName() {
        return middleName;
    }
    
    public String[] getTags() {
        return new String[0];
    }

    public @Nullable String[] getNullableTags() {
        return new String[0];
    }

    public String @Nullable [] getTagsOrNull() {
        return null;
    }

    public @Nullable String @Nullable [] getAllNullable() {
        return null;
    }
}

このように、クラスに対して@NullMarkedを付与する場合、その中のコンストラクタや関数の引数はNonNull相当となります。
このコードの中で、public @Nullable String @Nullable []みたいな2つアノテーションをつけている部分が気になるかもしれません。JSpecifyはTYPE_USE(型の使用箇所)に付けられるのでこのような表記が可能です。したがって、この箇所は「配列参照 nullable + 要素 nullable」という意味になります。

NullUnmarked混合

@NullMarkedが付与されたクラス内で、@NullUnmarkedを付与すると(今回の例であれば)返り値と引数がunspecified nullnessに戻ります。

package com.example.legacy;

import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.NullUnmarked;
import org.jspecify.annotations.Nullable;

@NullMarked
public final class MixedService {
    private final String configValue = "config";

    public String getConfig() {
        return configValue;
    }

    @NullUnmarked
    public String legacyMethod(String input) {
        return input;
    }

    public @Nullable String newMethod(String input) {
        return input.isEmpty() ? null : input;
    }
}

メソッドスコープ

メソッド単位でも付与することももちろん可能です。(途中からプロジェクトに導入したい際のリファクタリングなどにも活躍しそうですね。)

package com.example.util;

import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

public final class StringUtils {
    private StringUtils() {}

    @NullMarked
    public static String trim(String input) {
        return input.trim();
    }

    
    public static @Nullable String trimOrNull(@Nullable String input) {
        return input == null ? null : input.trim();
    }

}

アノテーションが使用できる場所

使用箇所のイメージ例を説明とともに記述したので、ご参考にしていただきたいと思います。

package com.example.recognized;

import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

import java.util.ArrayList;
import java.util.List;

@NullMarked
public final class RecognizedLocations {
    // ✅ フィールド型
    private @Nullable String nullableField;

    // ✅ コンストラクタ引数の型
    public RecognizedLocations(@Nullable String initial) {
        this.nullableField = initial;
    }

    // ✅ メソッド戻り値型
    public @Nullable String getField() {
        return nullableField;
    }

    // ✅ メソッド引数の型
    public void setField(@Nullable String value) {
        this.nullableField = value;
    }

    // ✅ 型パラメータ境界の型
    // 「T が null を取り得る」ことを境界で表す(T 自体の宣言に @Nullable を付けるのではない点が重要)。
    public <T extends @Nullable Object> void generic(T value) {}

    // ✅ 非ワイルドカードの型引数
    // List 自体は NonNull、要素型 String が nullable(= List に null 要素が入り得る)という意味。
    public List<@Nullable String> getNullableList() {
        return new ArrayList<>();
    }

    // ✅ ワイルドカード境界の型
    // 「@Nullable String のサブタイプ(= nullable な String)」を上限とする。
    public void processWildcard(List<? extends @Nullable String> list) {}

    // ✅ 配列要素型の型
    // これは「要素が nullable な String[]」を表す。配列参照自体は NonNull のまま。
    public @Nullable String[] getNullableArray() {
        return new String[10];
    }

    // ✅ 配列“自体”の型
    // これは「配列参照が nullable な String[]」を表す。要素 String は NonNull のまま。
    public String @Nullable [] getArrayOrNull() {
        return null;
    }

    // ✅ ネスト型の型使用位置
    // @Nullable は Outer ではなく Inner に付ける(Outer.@Nullable Inner)。
    public Outer.@Nullable Inner getInner() {
        return null;
    }

    public static final class Outer {
        public static final class Inner {}
    }
}

IntelliJ IDEAにおける挙動

冒頭部分でIntelliJ IDEA 2025.3では「JSpecifyが優先的なライブラリになった」という話をさせていただきました。ここでは、「JSpecify」と「JetBrains Annotations」の両方を導入している場合の挙動を紹介します。


IDEの指摘で自動挿入されるアノテーションがJspecifyになっている(ContractはJetbrains)

No と入力するとJSpecifyのNonNullが上位

Nu と入力するとJSpecifyのNullableが上位

NullUnmarkedのドキュメントツールの挙動

IntelliJで関数にマウスカーソルを当てたときに、付与されているアノテーションや引数の型が表示される機能があります。あくまで、直接付与されているアノテーションしか対象にならないため、NullUnmarked配下の関数に対して「@NonNull」の表示は出ず、警告による指摘しか来ないことに注意が必要です。

@NullMarked
public final class DemoMain {
    static void main() {
        IO.println(pingPong(null));

    }

    @Contract(pure = true)
    static String pingPong(String ping){
        if("ping".equals(ping)){
            return "pong";
        }
        return "no";
    }
}

その他 - TIPS

コンパイル境界をまたぐと、型アノテーションは javac プラグインから参照できない問題(JDK-8225377)

公式サイトの「Should you use JSpecify annotations in your Java code?」には以下のようなjavacに関するバグが紹介されています。(ChatGPTで日本語訳)

Dagger のように、クラスパス上のシンボルに付与された nullness アノテーションを解釈するアノテーションプロセッサに依存しているプロジェクトの場合、JDK 22 でビルドできるようになるまでは JSpecify アノテーションの採用を待つ必要があるかもしれません。
JDK 22 より前の javac にはバグ(JDK-8225377)があり、型使用アノテーション(JSpecify の @Nullable や @NonNull を含む)がクラスファイルから正しく読み取られませんでした。
これに対して、JSR-305 や AndroidX の nullness アノテーションは「宣言アノテーション」であるため、この問題の影響を受けません。ただし、その性質上、JSpecify のアノテーションのように、ジェネリクスの型引数や配列型の要素など、型の一部に適用することはできません。
この問題は JDK 22 から修正されていますが、その修正はまだ古いバージョンの javac にはバックポートされていません。
JDK 22 以降の javac でビルドできず、かつ Dagger などの同様のアノテーションプロセッサに依存している場合、現時点では JSpecify のアノテーションへ切り替えると問題が発生する可能性があります。
詳細については JSpecify issue 365 を参照してください。また、この修正を古いバージョンの javac にバックポートする取り組みについては、JDK-8323093 で進捗を追跡できます。

バグのイメージ

以下のようなコードがあったときに、

  • 同時コンパイルの場合はアノテーションプロセッサはBに関して、(Aを通じて)アノテーションが把握できる。
  • (差分コンパイルなどで)Aを先にコンパイルしたあとに、Bだけをコンパイルするとアノテーションプロセッサーが把握できない。

という感じです。

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.annotation.ElementType;

@Target(ElementType.TYPE_USE)
@Retention(RetentionPolicy.RUNTIME)
public @interface TypeAnnotation {}
import java.util.List;

public class A implements List<@TypeAnnotation String> {
    // List の実装は省略
}
public class B extends A {}

現状

先程の公式ドキュメントの説明でパックポートされていないので、JDK22以降(LTSであれば、JDK25)を推奨するとなっていました。
JDK-8225377の修正を遷移していくと、バックアウトされていることを確認しました(JDK-8322883など)。最新のチケットはJDK-8341779だと思われ、バックポートは17系のみ発行されている状況です。21系はOpenJDK 21.0.10に間に合わず、17系はOpenJDK 17.0.19のマージに間に合うかもという状況なので、リリースのタイムライン[9]を確認すると17系で2026年4月21日というのが修正が入りうるというのが、JDK22以前で最速になるのかなと思います。

JDK-8341779

IntelliJのJSpecifyの解釈がNullAwayと違う問題

冒頭のウェルスナビさんの資料の中にも記述があるのですが、IntelliJのJSpecifyの解釈がNullAwayと異なるという問題がありました。こちらの問題についてはJetBrainsのブログ[6:1]より、2025.3で解消したものと思われます。

[6:2]より 場合によっては設定変更が必要かもしれません

脚注
  1. JSpecify『Start Here』 ↩︎

  2. JSpecify『About Us』 ↩︎

  3. JSpecify トップベージ ↩︎

  4. JSpecify『Release 1.0.0』 ↩︎

  5. JetBrains『IntelliJ IDEA 2025.2 の新機能』 ↩︎

  6. Andrei Kogun『One Could Simply Add Nullability Check Support… Without Even Noticing It』 ↩︎ ↩︎ ↩︎

  7. Kotlin『What's new in Kotlin 1.5.20』 ↩︎

  8. Kotlin『What's new in Kotlin 2.1.0』 ↩︎

  9. Oracle『JDK 17u』 ↩︎

jig.jp Engineers' Blog

Discussion