Java Null Safety
Purpose
Turn null from an ambient hazard into a stated contract. Java is not null-safe and no
annotation makes it so at runtime — annotations are contracts, enforced only by the tools
that check them. The failure mode this skill prevents is the NPE thrown three layers and
twenty minutes away from the code that produced the null, because nothing between the two
said whether null was allowed.
Workflow
Inspect compiler release/toolchains, runtime, existing annotation vocabulary, checker scope,
JSpecify/tool versions and mapper configuration before edits. No single authoring baseline is
declared; references discuss JSpecify 1.0 and Java SE 25, while collection factories need Java 9+,
List.copyOf Java 10+, records and Stream.toList Java 16+. Use target-compatible alternatives;
do not upgrade or introduce a checker/dependency unless annotation adoption is in scope.
When tooling or a construction path is unavailable, report the gap rather than claiming null safety.
- Name what each null means. Absence (no promotion for this SKU), error (mandatory
field missing), or uninitialised (lifecycle not yet complete). Different meanings get
different treatments: absence becomes an empty collection or an Optional return, error
becomes an immediate throw, uninitialised becomes a documented lifecycle — or a design
to remove.
- Declare the default. Under JSpecify,
@NullMarked on the package or module makes
non-null the default and @Nullable the marked exception. An unannotated codebase has
the contract backwards: everything is implicitly a question.
- Enforce where ownership/trust changes. Constructors establish their own invariants;
adapters validate external values; public APIs enforce documented non-null preconditions.
Remove interior checks only when every construction/call path proves the contract.
- Fence the leaks. Deserialised DTOs, ORM relations,
Map.get, array slots and
varargs all deliver null regardless of your annotations. Convert to your contract at
the boundary, once.
- Verify. A checker (for example NullAway under Error Prone) wired into the build,
and a test feeding null through each boundary asserting it is rejected or normalised
there—not deeper. Pin checker/compiler versions and test generics, arrays, overrides and
unannotated dependencies because JSpecify support is not identical across tools.
Rules
- Public constructors and entry points reject values their contract marks non-null, preferably at
entry with a stable field/error identifier. In records this belongs in the compact
constructor;
List.copyOf rejects a null list and null elements in the same move.
- Prefer an empty collection when the contract means zero elements.
List.of() / Map.of() /
Set.of() are unmodifiable; preserve a published mutable-return contract with a fresh mutable
empty collection. Do not collapse unknown/not-loaded/error into empty without an explicit policy.
- Never claim an annotation prevents anything at runtime.
@NullMarked without a checker
in the build is documentation; with one, it is a compile-time contract. Say which.
Map.get returns null for both "absent" and "mapped to null" — resolve it with
getOrDefault when only absence should select a default (an explicitly mapped null remains
null), or collapse both to absence with Optional.ofNullable at the API edge. Only
containsKey distinguishes on a stable nullable map; separate calls race under concurrent
mutation. ConcurrentHashMap forbids null keys/values, making a single get unambiguous.
- Prefer empty collections and Optional/result/domain failures where they communicate absence well.
An explicitly
@Nullable public return is still a valid Java/JSpecify contract when framework
conventions, hot-path cost or migration compatibility justify it; callers and overrides must be
checked consistently. Unannotated ambient null is the defect, not every nullable API.
- Nullness has positions:
String @Nullable [] marks the array reference nullable, while
@Nullable String[] places nullability on its element type; generic element nullness likewise
differs from container nullness. Use JSpecify type-use syntax accepted by the chosen checker and
add compile tests for published signatures.
- Override contracts are directional: an implementation must not reject null accepted by its
supertype, and may return a non-null value where the supertype permits null. Run the checker on
both declarations; framework-generated subclasses and unannotated bytecode can hide violations.
- Primitive DTO fields cannot represent “missing” separately from zero/false when a binder applies
Java defaults. Use boxed/raw DTO fields, required-creator semantics or presence tracking at the
wire boundary, then convert to primitives after validation.
NPE diagnosis
- Read the helpful-NPE expression and full stack, but treat the dereference as the symptom—not
necessarily the producer.
- Trace assignments/returns back to the first nullable or unannotated boundary; classify absence,
invalid input or lifecycle state.
- Fix the producer contract/conversion and let the checker identify affected paths; avoid a local
if (x != null) that silently drops required work.
- Add a boundary regression and a compile-time nullness fixture. Verify logs/errors do not expose
sensitive object contents while diagnosing.
References
Deliver each null's meaning, affected declaration/boundary, chosen representation and checks
executed. Report checker version, analyzed scope and any unchecked dependencies; pair compile
fixtures with runtime boundary regressions. Neither annotations nor a zero-warning partial scan
prove that every runtime construction path is safe.
- Nullability contracts — read when introducing
JSpecify to a codebase, deciding annotation placement, or judging whether a flagged
nullable field is actually a defect.
- Worked example: hardening a service boundary — read
when NPEs originate from deserialised input or repository lookups, or before reviewing
an inbound adapter.
1---2name: java-null-safety3description: Null as a semantic problem, not a syntax problem: what each null means (absence, error, uninitialised), nullability as an API contract, JSpecify @NullMarked and @Nullable, where Objects.requireNonNull belongs, empty collections over null, and the boundaries where null leaks in (deserialisation, ORMs, Map.get, arrays). Use when an NPE surfaces far from its cause, when hardening a service or module boundary, when adopting nullability annotations, or when reviewing constructors and public entry points. Does not cover the Optional API — orElse/orElseGet, chaining, where Optional belongs — which is java-optional, nor general validation strategy at trust boundaries — range and state checks, normalisation — which is java-defensive-programming.4---56# Java Null Safety78## Purpose910Turn null from an ambient hazard into a stated contract. Java is not null-safe and no11annotation makes it so at runtime — annotations are contracts, enforced only by the tools12that check them. The failure mode this skill prevents is the NPE thrown three layers and13twenty minutes away from the code that produced the null, because nothing between the two14said whether null was allowed.1516## Workflow1718Inspect compiler release/toolchains, runtime, existing annotation vocabulary, checker scope,19JSpecify/tool versions and mapper configuration before edits. No single authoring baseline is20declared; references discuss JSpecify 1.0 and Java SE 25, while collection factories need Java 9+,21`List.copyOf` Java 10+, records and `Stream.toList` Java 16+. Use target-compatible alternatives;22do not upgrade or introduce a checker/dependency unless annotation adoption is in scope.23When tooling or a construction path is unavailable, report the gap rather than claiming null safety.24251. **Name what each null means.** Absence (no promotion for this SKU), error (mandatory26 field missing), or uninitialised (lifecycle not yet complete). Different meanings get27 different treatments: absence becomes an empty collection or an Optional return, error28 becomes an immediate throw, uninitialised becomes a documented lifecycle — or a design29 to remove.302. **Declare the default.** Under JSpecify, `@NullMarked` on the package or module makes31 non-null the default and `@Nullable` the marked exception. An unannotated codebase has32 the contract backwards: everything is implicitly a question.333. **Enforce where ownership/trust changes.** Constructors establish their own invariants;34 adapters validate external values; public APIs enforce documented non-null preconditions.35 Remove interior checks only when every construction/call path proves the contract.364. **Fence the leaks.** Deserialised DTOs, ORM relations, `Map.get`, array slots and37 varargs all deliver null regardless of your annotations. Convert to your contract at38 the boundary, once.395. **Verify.** A checker (for example NullAway under Error Prone) wired into the build,40 and a test feeding null through each boundary asserting it is rejected or normalised41 there—not deeper. Pin checker/compiler versions and test generics, arrays, overrides and42 unannotated dependencies because JSpecify support is not identical across tools.4344## Rules4546- Public constructors and entry points reject values their contract marks non-null, preferably at47 entry with a stable field/error identifier. In records this belongs in the compact48 constructor; `List.copyOf` rejects a null list and null elements in the same move.49- Prefer an empty collection when the contract means zero elements. `List.of()` / `Map.of()` /50 `Set.of()` are unmodifiable; preserve a published mutable-return contract with a fresh mutable51 empty collection. Do not collapse unknown/not-loaded/error into empty without an explicit policy.52- Never claim an annotation prevents anything at runtime. `@NullMarked` without a checker53 in the build is documentation; with one, it is a compile-time contract. Say which.54- `Map.get` returns null for both "absent" and "mapped to null" — resolve it with55 `getOrDefault` when only absence should select a default (an explicitly mapped null remains56 null), or collapse both to absence with `Optional.ofNullable` at the API edge. Only57 `containsKey` distinguishes on a stable nullable map; separate calls race under concurrent58 mutation. `ConcurrentHashMap` forbids null keys/values, making a single `get` unambiguous.59- Prefer empty collections and Optional/result/domain failures where they communicate absence well.60 An explicitly `@Nullable` public return is still a valid Java/JSpecify contract when framework61 conventions, hot-path cost or migration compatibility justify it; callers and overrides must be62 checked consistently. Unannotated ambient null is the defect, not every nullable API.63- Nullness has positions: `String @Nullable []` marks the array reference nullable, while64 `@Nullable String[]` places nullability on its element type; generic element nullness likewise65 differs from container nullness. Use JSpecify type-use syntax accepted by the chosen checker and66 add compile tests for published signatures.67- Override contracts are directional: an implementation must not reject null accepted by its68 supertype, and may return a non-null value where the supertype permits null. Run the checker on69 both declarations; framework-generated subclasses and unannotated bytecode can hide violations.70- Primitive DTO fields cannot represent “missing” separately from zero/false when a binder applies71 Java defaults. Use boxed/raw DTO fields, required-creator semantics or presence tracking at the72 wire boundary, then convert to primitives after validation.7374## NPE diagnosis75761. Read the helpful-NPE expression and full stack, but treat the dereference as the symptom—not77 necessarily the producer.782. Trace assignments/returns back to the first nullable or unannotated boundary; classify absence,79 invalid input or lifecycle state.803. Fix the producer contract/conversion and let the checker identify affected paths; avoid a local81 `if (x != null)` that silently drops required work.824. Add a boundary regression and a compile-time nullness fixture. Verify logs/errors do not expose83 sensitive object contents while diagnosing.8485## References8687Deliver each null's meaning, affected declaration/boundary, chosen representation and checks88executed. Report checker version, analyzed scope and any unchecked dependencies; pair compile89fixtures with runtime boundary regressions. Neither annotations nor a zero-warning partial scan90prove that every runtime construction path is safe.9192- [Nullability contracts](references/nullability-contracts.md) — read when introducing93 JSpecify to a codebase, deciding annotation placement, or judging whether a flagged94 nullable field is actually a defect.95- [Worked example: hardening a service boundary](references/boundary-hardening.md) — read96 when NPEs originate from deserialised input or repository lookups, or before reviewing97 an inbound adapter.