You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/06/16 21:38:18 UTC

(juneau) branch master updated: feat(marshall): null-inclusion knobs + first-class Optional*/BitSet datatypes

This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new b0b0671f97 feat(marshall): null-inclusion knobs + first-class Optional*/BitSet datatypes
b0b0671f97 is described below

commit b0b0671f97d69f9cae126c89619d00523eb985d0
Author: James Bognar <ja...@apache.org>
AuthorDate: Tue Jun 16 17:38:12 2026 -0400

    feat(marshall): null-inclusion knobs + first-class Optional*/BitSet datatypes
---
 .../juneau/assertions/VersionAssertion_Test.java   |  31 +-
 .../juneau/commons/bean/BeanPropertyMeta.java      |  20 +
 .../apache/juneau/commons/bean/BeanSession.java    |   2 +-
 .../juneau/commons/conversion/BasicConverter.java  |  53 ++
 .../commons/conversion/CachingConverter.java       |   6 +
 .../juneau/marshall/jena/RdfSerializerSession.java |   2 +-
 .../marshall/jena/RdfStreamSerializerSession.java  |   2 +-
 .../org/apache/juneau/marshall/BitSetFormat.java   | 210 ++++++++
 .../java/org/apache/juneau/marshall/ClassMeta.java |  41 ++
 .../org/apache/juneau/marshall/Marshalled.java     |   7 +
 .../juneau/marshall/MarshalledAnnotation.java      |  19 +
 .../apache/juneau/marshall/MarshalledConfig.java   |   7 +
 .../marshall/MarshalledConfigAnnotation.java       |   1 +
 .../org/apache/juneau/marshall/MarshalledProp.java |  39 ++
 .../juneau/marshall/MarshalledPropAnnotation.java  |  39 ++
 .../marshall/MarshalledPropertyPostProcessor.java  | 223 +++++++-
 .../apache/juneau/marshall/MarshallingContext.java |  36 +-
 .../juneau/marshall/MarshallingContextable.java    |  11 +
 .../marshall/MarshallingTraverseSession.java       |   6 +
 .../java/org/apache/juneau/marshall/Nulls.java     |  98 ++++
 .../marshall/hjson/HjsonSerializerSession.java     |   2 +-
 .../marshall/hocon/HoconSerializerSession.java     |   2 +-
 .../marshall/html/HtmlSerializerSession.java       |   2 +-
 .../juneau/marshall/ini/IniSerializerSession.java  |   2 +-
 .../juneau/marshall/jcs/JcsSerializerSession.java  |   3 +-
 .../marshall/json/JsonSerializerSession.java       |   2 +-
 .../org/apache/juneau/marshall/parser/Parser.java  |  58 ++-
 .../juneau/marshall/parser/ParserConfig.java       |  30 ++
 .../marshall/parser/ParserConfigAnnotation.java    |   1 +
 .../juneau/marshall/parser/ParserSession.java      |  42 +-
 .../marshall/proto/ProtoSerializerSession.java     |   2 +-
 .../juneau/marshall/serializer/Serializer.java     | 100 ++++
 .../marshall/serializer/SerializerConfig.java      |  31 ++
 .../serializer/SerializerConfigAnnotation.java     |   1 +
 .../marshall/serializer/SerializerSession.java     | 182 +++++++
 .../marshall/toml/TomlSerializerSession.java       |   2 +-
 .../juneau/marshall/uon/UonSerializerSession.java  |   2 +-
 .../urlencoding/UrlEncodingSerializerSession.java  |   2 +-
 .../juneau/marshall/xml/XmlSerializerSession.java  |   8 +-
 .../marshall/yaml/YamlSerializerSession.java       |   2 +-
 .../marshall/NullInclusionAndCoercion_Test.java    | 569 +++++++++++++++++++++
 scripts/coverage.py                                | 184 ++++++-
 42 files changed, 2011 insertions(+), 71 deletions(-)

diff --git a/juneau-core/juneau-assertions/src/test/java/org/apache/juneau/assertions/VersionAssertion_Test.java b/juneau-core/juneau-assertions/src/test/java/org/apache/juneau/assertions/VersionAssertion_Test.java
index db98993ad8..2116cc2388 100644
--- a/juneau-core/juneau-assertions/src/test/java/org/apache/juneau/assertions/VersionAssertion_Test.java
+++ b/juneau-core/juneau-assertions/src/test/java/org/apache/juneau/assertions/VersionAssertion_Test.java
@@ -68,7 +68,8 @@ class VersionAssertion_Test extends TestBase {
 		var x = of("1");
 		var nil = no(Version.class);
 		var s = Json5Serializer.DEFAULT;
-		test(x).asString(s).is("{maintenance:null,major:1,minor:null}");
+		// 10.0: Optional.empty() ≡ null under the shared Optional contract — omitted unless keepNullProperties().
+		test(x).asString(s).is("{major:1}");
 		test(nil).asString(s).is("null");
 	}
 
@@ -80,14 +81,14 @@ class VersionAssertion_Test extends TestBase {
 	@Test void ba02_asJson() {
 		var x = of("1");
 		var nil = no(Version.class);
-		test(x).asJson().is("{maintenance:null,major:1,minor:null}");
+		test(x).asJson().is("{major:1}");
 		test(nil).asJson().is("null");
 	}
 
 	@Test void ba03_asJsonSorted() {
 		var x = of("1");
 		var nil = no(Version.class);
-		test(x).asJsonSorted().is("{maintenance:null,major:1,minor:null}");
+		test(x).asJsonSorted().is("{major:1}");
 		test(nil).asJsonSorted().is("null");
 	}
 
@@ -230,9 +231,9 @@ class VersionAssertion_Test extends TestBase {
 		var nil = no(Version.class);
 		test(x1).isSameJsonAs(x1a);
 		test(nil).isSameJsonAs(nil);
-		assertThrown(()->test(x1a).isSameJsonAs(x2)).asMessage().asOneLine().is("Unexpected comparison.  Expect='{maintenance:null,major:2,minor:null}'.  Actual='{maintenance:null,major:1,minor:null}'.");
-		assertThrown(()->test(nil).isSameJsonAs(x2)).asMessage().asOneLine().is("Unexpected comparison.  Expect='{maintenance:null,major:2,minor:null}'.  Actual='null'.");
-		assertThrown(()->test(x1).isSameJsonAs(nil)).asMessage().asOneLine().is("Unexpected comparison.  Expect='null'.  Actual='{maintenance:null,major:1,minor:null}'.");
+		assertThrown(()->test(x1a).isSameJsonAs(x2)).asMessage().asOneLine().is("Unexpected comparison.  Expect='{major:2}'.  Actual='{major:1}'.");
+		assertThrown(()->test(nil).isSameJsonAs(x2)).asMessage().asOneLine().is("Unexpected comparison.  Expect='{major:2}'.  Actual='null'.");
+		assertThrown(()->test(x1).isSameJsonAs(nil)).asMessage().asOneLine().is("Unexpected comparison.  Expect='null'.  Actual='{major:1}'.");
 	}
 
 	@Test void ca10_isSameSortedJsonAs() {
@@ -242,9 +243,9 @@ class VersionAssertion_Test extends TestBase {
 		var nil = no(Version.class);
 		test(x1).isSameSortedJsonAs(x1a);
 		test(nil).isSameSortedJsonAs(nil);
-		assertThrown(()->test(x1a).isSameSortedJsonAs(x2)).asMessage().asOneLine().is("Unexpected comparison.  Expect='{maintenance:null,major:2,minor:null}'.  Actual='{maintenance:null,major:1,minor:null}'.");
-		assertThrown(()->test(nil).isSameSortedJsonAs(x2)).asMessage().asOneLine().is("Unexpected comparison.  Expect='{maintenance:null,major:2,minor:null}'.  Actual='null'.");
-		assertThrown(()->test(x1).isSameSortedJsonAs(nil)).asMessage().asOneLine().is("Unexpected comparison.  Expect='null'.  Actual='{maintenance:null,major:1,minor:null}'.");
+		assertThrown(()->test(x1a).isSameSortedJsonAs(x2)).asMessage().asOneLine().is("Unexpected comparison.  Expect='{major:2}'.  Actual='{major:1}'.");
+		assertThrown(()->test(nil).isSameSortedJsonAs(x2)).asMessage().asOneLine().is("Unexpected comparison.  Expect='{major:2}'.  Actual='null'.");
+		assertThrown(()->test(x1).isSameSortedJsonAs(nil)).asMessage().asOneLine().is("Unexpected comparison.  Expect='null'.  Actual='{major:1}'.");
 	}
 
 	@Test void ca11_isSameSerializedAs() {
@@ -255,9 +256,9 @@ class VersionAssertion_Test extends TestBase {
 		var s = Json5Serializer.DEFAULT;
 		test(x1).isSameSerializedAs(x1a, s);
 		test(nil).isSameSerializedAs(nil, s);
-		assertThrown(()->test(x1a).isSameSerializedAs(x2, s)).asMessage().asOneLine().is("Unexpected comparison.  Expect='{maintenance:null,major:2,minor:null}'.  Actual='{maintenance:null,major:1,minor:null}'.");
-		assertThrown(()->test(nil).isSameSerializedAs(x2, s)).asMessage().asOneLine().is("Unexpected comparison.  Expect='{maintenance:null,major:2,minor:null}'.  Actual='null'.");
-		assertThrown(()->test(x1).isSameSerializedAs(nil, s)).asMessage().asOneLine().is("Unexpected comparison.  Expect='null'.  Actual='{maintenance:null,major:1,minor:null}'.");
+		assertThrown(()->test(x1a).isSameSerializedAs(x2, s)).asMessage().asOneLine().is("Unexpected comparison.  Expect='{major:2}'.  Actual='{major:1}'.");
+		assertThrown(()->test(nil).isSameSerializedAs(x2, s)).asMessage().asOneLine().is("Unexpected comparison.  Expect='{major:2}'.  Actual='null'.");
+		assertThrown(()->test(x1).isSameSerializedAs(nil, s)).asMessage().asOneLine().is("Unexpected comparison.  Expect='null'.  Actual='{major:1}'.");
 	}
 
 	@Test void ca12_isType() {
@@ -293,10 +294,10 @@ class VersionAssertion_Test extends TestBase {
 	@Test void ca15_isJson() {
 		var x = of("1");
 		var nil = no(Version.class);
-		test(x).isJson("{maintenance:null,major:1,minor:null}");
+		test(x).isJson("{major:1}");
 		test(nil).isJson("null");
-		assertThrown(()->test(x).isJson("bad")).asMessage().asOneLine().is("String differed at position 0.  Expect='bad'.  Actual='{maintenance:null,major:1,minor:null}'.");
-		assertThrown(()->test(x).isJson(null)).asMessage().asOneLine().is("String differed at position 0.  Expect='null'.  Actual='{maintenance:null,major:1,minor:null}'.");
+		assertThrown(()->test(x).isJson("bad")).asMessage().asOneLine().is("String differed at position 0.  Expect='bad'.  Actual='{major:1}'.");
+		assertThrown(()->test(x).isJson(null)).asMessage().asOneLine().is("String differed at position 0.  Expect='null'.  Actual='{major:1}'.");
 		assertThrown(()->test(nil).isJson("bad")).asMessage().asOneLine().is("String differed at position 0.  Expect='bad'.  Actual='null'.");
 	}
 
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanPropertyMeta.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanPropertyMeta.java
index 78e37cd0cd..459d393120 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanPropertyMeta.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanPropertyMeta.java
@@ -60,6 +60,20 @@ import org.apache.juneau.commons.reflect.*;
 })
 public class BeanPropertyMeta implements Comparable<BeanPropertyMeta> {
 
+	/**
+	 * Sentinel value that, when returned from {@link BeanPropertyMeta.Builder#writeTransform} during
+	 * {@link #set(BeanMap, String, Object)}, causes the set to be skipped entirely — neither the configured setter
+	 * is invoked nor the property cache updated.
+	 *
+	 * <p>
+	 * Provided as a generic SPI hook so the marshalling layer (or any other transform-installing extension) can
+	 * implement "leave the bean's pre-existing value in place" semantics — used by the per-property null-coercion
+	 * {@code SKIP} mode added to the marshalling layer in 10.0.0.
+	 *
+	 * @since 10.0.0
+	 */
+	public static final Object SKIP_VALUE = new Object();
+
 	// Property name constants
 	private static final String PROP_field = "field";
 	private static final String PROP_getter = "getter";
@@ -1093,6 +1107,12 @@ public class BeanPropertyMeta implements Comparable<BeanPropertyMeta> {
 			// Apply the install-time write transform (identity by default; swap-aware in the marshalling layer).
 			value1 = writeTransform.apply(session, value1);
 
+			// Generic SPI hook: a transform that returns SKIP_VALUE causes the set to be skipped entirely so the
+			// bean's pre-existing value (typically a field-initializer) is preserved.  Used by the marshalling
+			// layer's per-property null-coercion SKIP mode.
+			if (value1 == SKIP_VALUE)
+				return null;
+
 			if (m.bean == null) {
 
 				// Read-only beans get their properties stored in a cache.
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanSession.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanSession.java
index e949e4dc65..cb38da1d0b 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanSession.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanSession.java
@@ -143,7 +143,7 @@ public interface BeanSession {
 	 * <p>
 	 * Default-view-inclusion policy (configurable): an untagged property — one whose view set is empty — is
 	 * considered in-view under every active view unless the
-	 * {@link org.apache.juneau.marshall.MarshallingContext.Builder#disableDefaultViewInclusion()} flag is set.
+	 * {@code MarshallingContext.Builder.disableDefaultViewInclusion()} flag is set.
 	 *
 	 * @param pMeta The property metadata. Must not be <jk>null</jk>.
 	 * @return <jk>true</jk> if the property should be included under the current active view.
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/conversion/BasicConverter.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/conversion/BasicConverter.java
index b8809a6585..a86833d07e 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/conversion/BasicConverter.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/conversion/BasicConverter.java
@@ -208,6 +208,9 @@ public class BasicConverter extends CachingConverter {
 
 		if (out == Optional.class && (c = findOptionalConversion()) != null) return c;
 
+		if ((out == OptionalInt.class || out == OptionalLong.class || out == OptionalDouble.class)
+				&& (c = findPrimitiveOptionalConversion(out)) != null) return c;
+
 		if (Collection.class.isAssignableFrom(out) && (c = findCollectionConversion(inType, out)) != null) return c;
 
 		if (Map.class.isAssignableFrom(out) && (c = findMapConversion(inType, out)) != null) return c;
@@ -355,6 +358,56 @@ public class BasicConverter extends CachingConverter {
 		};
 	}
 
+	/**
+	 * Conversion to a primitive {@link OptionalInt}/{@link OptionalLong}/{@link OptionalDouble}.
+	 *
+	 * <p>
+	 * Mirrors the {@link Optional} contract: a {@code null} input (or an empty optional of any flavor)
+	 * yields the matching empty primitive optional; any other value is unwrapped (if it is itself an
+	 * optional) and coerced to the matching boxed number before wrapping.
+	 *
+	 * @param out The target primitive-optional class.
+	 * @return The conversion, never <jk>null</jk>.
+	 */
+	private <I, O> Conversion<I, O> findPrimitiveOptionalConversion(Class<O> out) {
+		final Class<? extends Number> elementType;
+		if (out == OptionalInt.class)
+			elementType = Integer.class;
+		else if (out == OptionalLong.class)
+			elementType = Long.class;
+		else
+			elementType = Double.class;
+		return (Conversion<I, O>) (Conversion<Object, Object>) (in, memberOf, session, args) -> {
+			Object v = in;
+			if (v instanceof Optional<?> opt)
+				v = opt.orElse(null);
+			else if (v instanceof OptionalInt oi)
+				v = oi.isPresent() ? Integer.valueOf(oi.getAsInt()) : null;
+			else if (v instanceof OptionalLong ol)
+				v = ol.isPresent() ? Long.valueOf(ol.getAsLong()) : null;
+			else if (v instanceof OptionalDouble od)
+				v = od.isPresent() ? Double.valueOf(od.getAsDouble()) : null;
+			if (v == null)
+				return emptyPrimitiveOptional(out);
+			var n = (Number) to(v, memberOf, session, elementType);
+			if (n == null)
+				return emptyPrimitiveOptional(out);
+			if (out == OptionalInt.class)
+				return OptionalInt.of(n.intValue());
+			if (out == OptionalLong.class)
+				return OptionalLong.of(n.longValue());
+			return OptionalDouble.of(n.doubleValue());
+		};
+	}
+
+	private static Object emptyPrimitiveOptional(Class<?> out) {
+		if (out == OptionalInt.class)
+			return OptionalInt.empty();
+		if (out == OptionalLong.class)
+			return OptionalLong.empty();
+		return OptionalDouble.empty();
+	}
+
 	//-----------------------------------------------------------------------------------------------------------------
 	// Collection conversions
 	//-----------------------------------------------------------------------------------------------------------------
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/conversion/CachingConverter.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/conversion/CachingConverter.java
index 825f51a889..dc76227469 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/conversion/CachingConverter.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/conversion/CachingConverter.java
@@ -83,6 +83,12 @@ public abstract class CachingConverter implements Converter {
 				return (T) opt(nullDefault(args[0], Arrays.copyOfRange(args, 1, args.length)));
 			return (T) opte();
 		}
+		if (type == OptionalInt.class)
+			return (T) OptionalInt.empty();
+		if (type == OptionalLong.class)
+			return (T) OptionalLong.empty();
+		if (type == OptionalDouble.class)
+			return (T) OptionalDouble.empty();
 		return type.isPrimitive() ? primitiveDefault(type) : null;
 	}
 
diff --git a/juneau-core/juneau-marshall-rdf/src/main/java/org/apache/juneau/marshall/jena/RdfSerializerSession.java b/juneau-core/juneau-marshall-rdf/src/main/java/org/apache/juneau/marshall/jena/RdfSerializerSession.java
index 16b50a19a5..b1f77fe6f8 100644
--- a/juneau-core/juneau-marshall-rdf/src/main/java/org/apache/juneau/marshall/jena/RdfSerializerSession.java
+++ b/juneau-core/juneau-marshall-rdf/src/main/java/org/apache/juneau/marshall/jena/RdfSerializerSession.java
@@ -365,7 +365,7 @@ public class RdfSerializerSession extends WriterSerializerSession {
 			if (nn(t))
 				onBeanGetterException(bpMeta, t);
 
-			if (canIgnoreValue(cMeta, key, value))
+			if (canIgnoreValue(bpMeta, key, value))
 				return;
 
 			var ns = bpRdf.getNamespace();
diff --git a/juneau-core/juneau-marshall-rdf/src/main/java/org/apache/juneau/marshall/jena/RdfStreamSerializerSession.java b/juneau-core/juneau-marshall-rdf/src/main/java/org/apache/juneau/marshall/jena/RdfStreamSerializerSession.java
index 4f57bfd4f7..705fcce26d 100644
--- a/juneau-core/juneau-marshall-rdf/src/main/java/org/apache/juneau/marshall/jena/RdfStreamSerializerSession.java
+++ b/juneau-core/juneau-marshall-rdf/src/main/java/org/apache/juneau/marshall/jena/RdfStreamSerializerSession.java
@@ -409,7 +409,7 @@ public class RdfStreamSerializerSession extends OutputStreamSerializerSession {
 			var t = x.getThrown();
 			if (nn(t))
 				onBeanGetterException(bpMeta, t);
-			if (canIgnoreValue(cMeta, key, value))
+			if (canIgnoreValue(bpMeta, key, value))
 				return;
 			var ns = bpRdf.getNamespace();
 			if (ns == null && ctx.isUseXmlNamespaces())
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/BitSetFormat.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/BitSetFormat.java
new file mode 100644
index 0000000000..9ba4c3f454
--- /dev/null
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/BitSetFormat.java
@@ -0,0 +1,210 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.marshall;
+
+import static org.apache.juneau.commons.utils.ThrowableUtils.*;
+
+import java.util.*;
+import java.util.stream.*;
+
+/**
+ * Supported wire formats for {@link BitSet} values.
+ *
+ * <p>
+ * Used by {@link MarshallingContext.Builder#bitSetFormat(BitSetFormat)},
+ * {@link org.apache.juneau.marshall.Marshalled#bitSetFormat()},
+ * {@link org.apache.juneau.marshall.MarshalledProp#bitSetFormat()}, and
+ * {@link org.apache.juneau.marshall.MarshalledConfig#bitSetFormat()} to control how {@link BitSet} values
+ * are written to text-based wire formats.
+ *
+ * <p>
+ * The default is {@link #INDICES} which emits the ascending set-bit indices as a comma-delimited token.
+ *
+ * <h5 class='topic'>Precedence (highest to lowest)</h5>
+ * <ol>
+ * 	<li>{@link org.apache.juneau.marshall.MarshalledProp#bitSetFormat() @MarshalledProp(bitSetFormat=…)} on the bean property.
+ * 	<li>{@link org.apache.juneau.marshall.Marshalled#bitSetFormat() @Marshalled(bitSetFormat=…)} on the bean class.
+ * 	<li>{@link org.apache.juneau.marshall.MarshalledConfig#bitSetFormat() @MarshalledConfig(bitSetFormat=…)} on
+ * 		<code><ja>@Rest</ja></code>-annotated classes / methods.
+ * 	<li>Programmatic {@link MarshallingContext.Builder#bitSetFormat(BitSetFormat)}.
+ * 	<li>Environment variable <c>MarshallingContext.bitSetFormat</c>.
+ * 	<li>The default constant ({@link #INDICES}).
+ * </ol>
+ *
+ * <h5 class='topic'>Parsing</h5>
+ *
+ * <p>
+ * Parsing is directed by the configured constant — a serializer and parser sharing the same
+ * {@code BitSetFormat} round-trip cleanly.  The three textual shapes are ambiguous with each other
+ * (a bare digit string is a valid index list, bit string, and hex token), so the configured format is
+ * honored rather than auto-detected.
+ */
+public enum BitSetFormat {
+
+	/** Sentinel meaning "no value configured" — falls through to the next-higher precedence level. */
+	NOT_SET,
+
+	/**
+	 * Ascending set-bit indices as a comma-delimited token (the default).
+	 *
+	 * <h5 class='figure'>Example:</h5>
+	 * <p class='bcode'>
+	 * 	0,2,5
+	 * </p>
+	 *
+	 * <p>
+	 * An empty {@link BitSet} emits the empty string.
+	 */
+	INDICES,
+
+	/**
+	 * Little-endian bit string — character at position <c>i</c> is <c>'1'</c> when bit <c>i</c> is set.
+	 *
+	 * <h5 class='figure'>Example:</h5>
+	 * <p class='bcode'>
+	 * 	101001
+	 * </p>
+	 *
+	 * <p>
+	 * The string length equals {@link BitSet#length()}; an empty {@link BitSet} emits the empty string.
+	 */
+	BITS,
+
+	/**
+	 * Hex-encoded little-endian byte array ({@link BitSet#toByteArray()}).
+	 *
+	 * <h5 class='figure'>Example:</h5>
+	 * <p class='bcode'>
+	 * 	25
+	 * </p>
+	 *
+	 * <p>
+	 * An empty {@link BitSet} emits the empty string.
+	 */
+	HEX;
+
+	private static final char[] HEX_CHARS = "0123456789abcdef".toCharArray();
+
+	/**
+	 * Formats the specified {@link BitSet} using this format.
+	 *
+	 * <p>
+	 * {@link #NOT_SET} falls through to {@link #INDICES}.
+	 *
+	 * @param value The value to format.  Can be <jk>null</jk>.
+	 * @param format The format to use.  Can be <jk>null</jk> (treated as {@link #INDICES}).
+	 * @return The formatted wire representation, or <jk>null</jk> if {@code value} is <jk>null</jk>.
+	 */
+	public static String format(BitSet value, BitSetFormat format) {
+		if (value == null)
+			return null;
+		var fmt = format == null ? INDICES : format;
+		return switch (fmt) {
+			case NOT_SET, INDICES -> value.stream().mapToObj(Integer::toString).collect(Collectors.joining(","));
+			case BITS -> formatBits(value);
+			case HEX -> formatHex(value.toByteArray());
+		};
+	}
+
+	private static String formatBits(BitSet value) {
+		var len = value.length();
+		var sb = new StringBuilder(len);
+		for (var i = 0; i < len; i++)
+			sb.append(value.get(i) ? '1' : '0');
+		return sb.toString();
+	}
+
+	private static String formatHex(byte[] bytes) {
+		var sb = new StringBuilder(bytes.length * 2);
+		for (var b : bytes) {
+			sb.append(HEX_CHARS[(b >> 4) & 0xF]);
+			sb.append(HEX_CHARS[b & 0xF]);
+		}
+		return sb.toString();
+	}
+
+	/**
+	 * Parses the specified wire value into a {@link BitSet}.
+	 *
+	 * <p>
+	 * Parsing is directed by the {@code format} hint (defaulting to {@link #INDICES}); the three textual
+	 * shapes are mutually ambiguous, so the configured format is honored rather than auto-detected.
+	 *
+	 * @param value The wire value.  Can be <jk>null</jk> or blank.
+	 * @param format The configured format hint.  Can be <jk>null</jk> (treated as {@link #INDICES}).
+	 * @return The parsed {@link BitSet}, or <jk>null</jk> if {@code value} is <jk>null</jk>.
+	 * @throws IllegalArgumentException If the value does not match the configured textual shape.
+	 */
+	public static BitSet parse(String value, BitSetFormat format) {
+		if (value == null)
+			return null;
+		var s = value.trim();
+		var fmt = format == null ? INDICES : format;
+		var bs = new BitSet();
+		if (s.isEmpty())
+			return bs;
+		try {
+			switch (fmt) {
+				case NOT_SET, INDICES -> {
+					for (var tok : s.split(","))
+						bs.set(Integer.parseInt(tok.trim()));
+				}
+				case BITS -> {
+					for (var i = 0; i < s.length(); i++)
+						if (s.charAt(i) == '1')
+							bs.set(i);
+				}
+				case HEX -> {
+					return BitSet.valueOf(parseHex(s));
+				}
+			}
+		} catch (IllegalArgumentException e) {
+			throw illegalArg("Invalid BitSet value ''{0}'' for format {1}: {2}", value, fmt, e.getMessage());
+		}
+		return bs;
+	}
+
+	private static byte[] parseHex(String s) {
+		if ((s.length() & 1) != 0)
+			throw illegalArg("Hex string must have an even number of characters");
+		var out = new byte[s.length() / 2];
+		for (var i = 0; i < out.length; i++) {
+			var hi = Character.digit(s.charAt(i * 2), 16);
+			var lo = Character.digit(s.charAt(i * 2 + 1), 16);
+			if (hi < 0 || lo < 0)
+				throw illegalArg("Invalid hex character");
+			out[i] = (byte) ((hi << 4) | lo);
+		}
+		return out;
+	}
+
+	/**
+	 * Returns <jk>true</jk> if this format is numeric on the wire.
+	 *
+	 * <p>
+	 * Always <jk>false</jk> — every constant emits a textual representation.
+	 *
+	 * @return <jk>false</jk>.
+	 */
+	@SuppressWarnings({
+		"static-method", // Kept as an instance method for polymorphic-by-convention symmetry with the other Format classes (BigNumberFormat, FloatFormat, DurationFormat, etc.) where isNumeric() depends on the enum constant.
+		"java:S3400"     // Same rationale — must remain an instance method, not a constant, to match the cross-Format API contract.
+	})
+	public boolean isNumeric() {
+		return false;
+	}
+}
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ClassMeta.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ClassMeta.java
index a5aa88ef76..c39c271bb2 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ClassMeta.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ClassMeta.java
@@ -675,11 +675,44 @@ public final class ClassMeta<T> extends BeanInfo<T> {
 	})
 	@Override
 	public Optional<?> getOptionalDefault() {
+		if (isPrimitiveOptional())
+			return Optional.empty();
 		if (isOptional())
 			return opt(getElementType().getOptionalDefault());
 		return null;
 	}
 
+	/**
+	 * Returns <jk>true</jk> if this is one of the primitive {@link Optional} cousins
+	 * ({@link OptionalInt}, {@link OptionalLong}, or {@link OptionalDouble}).
+	 *
+	 * <p>
+	 * These types carry no generic element type but are treated as first-class optional types by the
+	 * marshallers (mirroring the {@link Optional} contract — empty ≡ null/absent, present unwraps to the
+	 * primitive number).  See {@link #getElementType()} which synthesizes the matching boxed element type.
+	 *
+	 * @return <jk>true</jk> if this is {@link OptionalInt}/{@link OptionalLong}/{@link OptionalDouble}.
+	 */
+	public boolean isPrimitiveOptional() {
+		return is(OptionalInt.class) || is(OptionalLong.class) || is(OptionalDouble.class);
+	}
+
+	/**
+	 * Returns <jk>true</jk> if this class is {@link Optional} or one of its primitive cousins
+	 * ({@link OptionalInt}/{@link OptionalLong}/{@link OptionalDouble}).
+	 *
+	 * <p>
+	 * Overrides {@link org.apache.juneau.commons.reflect.ClassInfo#isOptional()} so the marshalling layer
+	 * treats the three primitive optional types as first-class optionals (synthetic boxed element type,
+	 * empty-as-null contract) without registering swaps for them.
+	 *
+	 * @return <jk>true</jk> if this is an optional-like type.
+	 */
+	@Override
+	public boolean isOptional() {
+		return super.isOptional() || isPrimitiveOptional();
+	}
+
 	/**
 	 * Returns the method or field annotated with {@link ParentProperty @ParentProperty}.
 	 *
@@ -1495,6 +1528,14 @@ public final class ClassMeta<T> extends BeanInfo<T> {
 	private ClassMeta<?> findElementType() {
 		if (marshallingContext == null)
 			return null;
+		// Primitive optional types carry no generic parameter — synthesize the matching boxed element type so
+		// the first-class optional path (serialize unwrap / parse element-typed wrap) has a target type.
+		if (is(OptionalInt.class))
+			return marshallingContext.getClassMeta(Integer.class);
+		if (is(OptionalLong.class))
+			return marshallingContext.getClassMeta(Long.class);
+		if (is(OptionalDouble.class))
+			return marshallingContext.getClassMeta(Double.class);
 		if (cat.is(ARRAY)) {
 			return marshallingContext.getClassMeta(inner().getComponentType());
 		} else if (cat.is(COLLECTION) || cat.is(ITERABLE) || cat.is(ITERATOR) || cat.is(STREAM) || is(Optional.class)) {
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/Marshalled.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/Marshalled.java
index 2663ce0711..7859da2f2b 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/Marshalled.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/Marshalled.java
@@ -170,6 +170,13 @@ public @interface Marshalled {
 	 */
 	UuidFormat uuidFormat() default UuidFormat.NOT_SET;
 
+	/**
+	 * BitSet wire format override.
+	 *
+	 * @return The annotation value.
+	 */
+	BitSetFormat bitSetFormat() default BitSetFormat.NOT_SET;
+
 	/**
 	 * Big-number wire format override for {@link BigInteger} / {@link BigDecimal} values.
 	 *
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledAnnotation.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledAnnotation.java
index 7fae59ca04..aad4d1f8d8 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledAnnotation.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledAnnotation.java
@@ -62,6 +62,7 @@ public class MarshalledAnnotation {
 		private BinaryFormat binaryFormat = BinaryFormat.NOT_SET;
 		private EnumFormat enumFormat = EnumFormat.NOT_SET;
 		private UuidFormat uuidFormat = UuidFormat.NOT_SET;
+		private BitSetFormat bitSetFormat = BitSetFormat.NOT_SET;
 		private BigNumberFormat bigNumberFormat = BigNumberFormat.NOT_SET;
 		private BooleanFormat booleanFormat = BooleanFormat.NOT_SET;
 		private FloatFormat floatFormat = FloatFormat.NOT_SET;
@@ -238,6 +239,17 @@ public class MarshalledAnnotation {
 			return this;
 		}
 
+		/**
+		 * Sets the {@link Marshalled#bitSetFormat()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder bitSetFormat(BitSetFormat value) {
+			bitSetFormat = value;
+			return this;
+		}
+
 		/**
 		 * Sets the {@link Marshalled#bigNumberFormat()} property on this annotation.
 		 *
@@ -374,6 +386,7 @@ public class MarshalledAnnotation {
 		private final BinaryFormat binaryFormat;
 		private final EnumFormat enumFormat;
 		private final UuidFormat uuidFormat;
+		private final BitSetFormat bitSetFormat;
 		private final BigNumberFormat bigNumberFormat;
 		private final BooleanFormat booleanFormat;
 		private final FloatFormat floatFormat;
@@ -401,6 +414,7 @@ public class MarshalledAnnotation {
 			binaryFormat = b.binaryFormat;
 			enumFormat = b.enumFormat;
 			uuidFormat = b.uuidFormat;
+			bitSetFormat = b.bitSetFormat;
 			bigNumberFormat = b.bigNumberFormat;
 			booleanFormat = b.booleanFormat;
 			floatFormat = b.floatFormat;
@@ -473,6 +487,11 @@ public class MarshalledAnnotation {
 			return uuidFormat;
 		}
 
+		@Override /* Overridden from Marshalled */
+		public BitSetFormat bitSetFormat() {
+			return bitSetFormat;
+		}
+
 		@Override /* Overridden from Marshalled */
 		public BigNumberFormat bigNumberFormat() {
 			return bigNumberFormat;
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledConfig.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledConfig.java
index 89ca24d1e3..9e885ec2a5 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledConfig.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledConfig.java
@@ -296,6 +296,13 @@ public @interface MarshalledConfig {
 	 */
 	UuidFormat uuidFormat() default UuidFormat.NOT_SET;
 
+	/**
+	 * BitSet wire format.
+	 *
+	 * @return The annotation value.
+	 */
+	BitSetFormat bitSetFormat() default BitSetFormat.NOT_SET;
+
 	/**
 	 * Big-number wire format for {@link BigInteger} / {@link BigDecimal} values.
 	 *
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledConfigAnnotation.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledConfigAnnotation.java
index 3dacc653be..18b802042d 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledConfigAnnotation.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledConfigAnnotation.java
@@ -67,6 +67,7 @@ public class MarshalledConfigAnnotation {
 			if (a.binaryFormat() != BinaryFormat.NOT_SET) b.binaryFormat(a.binaryFormat());
 			if (a.enumFormat() != EnumFormat.NOT_SET) b.enumFormat(a.enumFormat());
 			if (a.uuidFormat() != UuidFormat.NOT_SET) b.uuidFormat(a.uuidFormat());
+			if (a.bitSetFormat() != BitSetFormat.NOT_SET) b.bitSetFormat(a.bitSetFormat());
 			if (a.bigNumberFormat() != BigNumberFormat.NOT_SET) b.bigNumberFormat(a.bigNumberFormat());
 			if (a.booleanFormat() != BooleanFormat.NOT_SET) b.booleanFormat(a.booleanFormat());
 			if (a.floatFormat() != FloatFormat.NOT_SET) b.floatFormat(a.floatFormat());
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledProp.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledProp.java
index 186975d3e8..608c1dd293 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledProp.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledProp.java
@@ -233,6 +233,13 @@ public @interface MarshalledProp {
 	 */
 	UuidFormat uuidFormat() default UuidFormat.NOT_SET;
 
+	/**
+	 * BitSet wire format override for {@link BitSet} property values.
+	 *
+	 * @return The annotation value.
+	 */
+	BitSetFormat bitSetFormat() default BitSetFormat.NOT_SET;
+
 	/**
 	 * Big-number wire format override for {@link BigInteger} / {@link BigDecimal} property values.
 	 *
@@ -267,4 +274,36 @@ public @interface MarshalledProp {
 	 * @return The annotation value.
 	 */
 	ClassFormat classFormat() default ClassFormat.NOT_SET;
+
+	/**
+	 * Per-property null-coercion policy applied when a {@code null} JSON-equivalent value reaches this property during
+	 * parsing.
+	 *
+	 * <p>
+	 * Juneau's analog of Jackson's {@code @JsonSetter(nulls=…)}.  When the parser encounters an explicit {@code null}
+	 * (or an absent {@link Optional}) for this property, the configured policy decides whether to:
+	 * <ul>
+	 * 	<li>{@link Nulls#LEAVE LEAVE} (default) — set the property to {@code null} (or, for an {@link Optional}-typed
+	 * 		property, to {@link Optional#empty()}).
+	 * 	<li>{@link Nulls#EMPTY EMPTY} — substitute the type's "empty" value (empty {@link String}/{@link java.util.Collection}/
+	 * 		{@link java.util.Map}, primitive default for primitives, {@code Optional.empty()} for {@link Optional}).
+	 * 	<li>{@link Nulls#DEFAULT DEFAULT} — substitute the bean-constructed default for the property (i.e. the value the
+	 * 		property holds on a fresh no-arg-constructed instance of the bean).  When no reference instance can be built,
+	 * 		falls back to {@link Nulls#LEAVE LEAVE}.
+	 * 	<li>{@link Nulls#SKIP SKIP} — do not call the setter at all, leaving any pre-existing value in place.
+	 * </ul>
+	 *
+	 * <p>
+	 * For an {@link Optional}-typed property, {@code EMPTY}/{@code DEFAULT} resolve to {@link Optional#empty()} —
+	 * never a bare {@code null} inside an {@link Optional}.  The same contract applies to {@link java.util.OptionalInt},
+	 * {@link java.util.OptionalLong}, and {@link java.util.OptionalDouble}.
+	 *
+	 * <p>
+	 * When this member is {@link Nulls#NOT_SET NOT_SET} (the default), the context-level default configured on
+	 * {@link org.apache.juneau.marshall.parser.Parser.Builder#nulls(Nulls)} applies.
+	 *
+	 * @return The annotation value.
+	 * @since 10.0.0
+	 */
+	Nulls nulls() default Nulls.NOT_SET;
 }
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledPropAnnotation.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledPropAnnotation.java
index 7fba4e6a60..c6d8487bc3 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledPropAnnotation.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledPropAnnotation.java
@@ -56,11 +56,13 @@ public class MarshalledPropAnnotation {
 		private BinaryFormat binaryFormat = BinaryFormat.NOT_SET;
 		private EnumFormat enumFormat = EnumFormat.NOT_SET;
 		private UuidFormat uuidFormat = UuidFormat.NOT_SET;
+		private BitSetFormat bitSetFormat = BitSetFormat.NOT_SET;
 		private BigNumberFormat bigNumberFormat = BigNumberFormat.NOT_SET;
 		private BooleanFormat booleanFormat = BooleanFormat.NOT_SET;
 		private FloatFormat floatFormat = FloatFormat.NOT_SET;
 		private CurrencyFormat currencyFormat = CurrencyFormat.NOT_SET;
 		private ClassFormat classFormat = ClassFormat.NOT_SET;
+		private Nulls nulls = Nulls.NOT_SET;
 
 		/**
 		 * Constructor.
@@ -221,6 +223,17 @@ public class MarshalledPropAnnotation {
 			return this;
 		}
 
+		/**
+		 * Sets the {@link MarshalledProp#bitSetFormat()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public Builder bitSetFormat(BitSetFormat value) {
+			bitSetFormat = value;
+			return this;
+		}
+
 		/**
 		 * Sets the {@link MarshalledProp#bigNumberFormat()} property on this annotation.
 		 *
@@ -287,6 +300,18 @@ public class MarshalledPropAnnotation {
 			return this;
 		}
 
+		/**
+		 * Sets the {@link MarshalledProp#nulls()} property on this annotation.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 * @since 10.0.0
+		 */
+		public Builder nulls(Nulls value) {
+			nulls = value;
+			return this;
+		}
+
 	}
 
 	@SuppressWarnings({
@@ -308,11 +333,13 @@ public class MarshalledPropAnnotation {
 		private final BinaryFormat binaryFormat;
 		private final EnumFormat enumFormat;
 		private final UuidFormat uuidFormat;
+		private final BitSetFormat bitSetFormat;
 		private final BigNumberFormat bigNumberFormat;
 		private final BooleanFormat booleanFormat;
 		private final FloatFormat floatFormat;
 		private final CurrencyFormat currencyFormat;
 		private final ClassFormat classFormat;
+		private final Nulls nulls;
 
 		Object(MarshalledPropAnnotation.Builder b) {
 			super(b);
@@ -330,11 +357,13 @@ public class MarshalledPropAnnotation {
 			binaryFormat = b.binaryFormat;
 			enumFormat = b.enumFormat;
 			uuidFormat = b.uuidFormat;
+			bitSetFormat = b.bitSetFormat;
 			bigNumberFormat = b.bigNumberFormat;
 			booleanFormat = b.booleanFormat;
 			floatFormat = b.floatFormat;
 			currencyFormat = b.currencyFormat;
 			classFormat = b.classFormat;
+			nulls = b.nulls;
 		}
 
 		@Override /* Overridden from MarshalledProp */
@@ -397,6 +426,11 @@ public class MarshalledPropAnnotation {
 			return uuidFormat;
 		}
 
+		@Override /* Overridden from MarshalledProp */
+		public BitSetFormat bitSetFormat() {
+			return bitSetFormat;
+		}
+
 		@Override /* Overridden from MarshalledProp */
 		public BigNumberFormat bigNumberFormat() {
 			return bigNumberFormat;
@@ -431,6 +465,11 @@ public class MarshalledPropAnnotation {
 		public String[] view() {
 			return view;
 		}
+
+		@Override /* Overridden from MarshalledProp */
+		public Nulls nulls() {
+			return nulls;
+		}
 	}
 
 	/** Default value */
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledPropertyPostProcessor.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledPropertyPostProcessor.java
index 662750cafe..6e038440d4 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledPropertyPostProcessor.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshalledPropertyPostProcessor.java
@@ -107,6 +107,10 @@ final class MarshalledPropertyPostProcessor implements BeanPropertyPostProcessor
 		var bdClasses = new ArrayList<Class<?>>();
 		Set<String> viewNames = null;
 		var propertyClass = propertyClass(b);
+		// Per-property null-coercion mode discovered across innerField/getter/setter (last-seen-wins, matching the
+		// existing format-override loop semantics).  Stays Nulls.NOT_SET when no @MarshalledProp annotation
+		// declares it, in which case only the context-level Parser.Builder#nulls(Nulls) default applies.
+		var perPropertyNulls = Nulls.NOT_SET;
 
 		// XMLGregorianCalendar always uses XML format regardless of any CalendarFormat setting.
 		if (b.swap == null && XMLGregorianCalendar.class.equals(propertyClass))
@@ -123,8 +127,11 @@ final class MarshalledPropertyPostProcessor implements BeanPropertyPostProcessor
 			});
 			ap.find(Swap.class, b.innerField).stream().findFirst().ifPresent(x -> b.swap = swapSwap(x));
 			b.isUri |= ap.has(Uri.class, b.innerField);
-			for (var ai : ap.find(MarshalledProp.class, b.innerField))
+			for (var ai : ap.find(MarshalledProp.class, b.innerField)) {
 				viewNames = addViews(viewNames, ai.inner().view());
+				if (ai.inner().nulls() != Nulls.NOT_SET)
+					perPropertyNulls = ai.inner().nulls();
+			}
 		}
 
 		if (nn(b.getter)) {
@@ -137,8 +144,11 @@ final class MarshalledPropertyPostProcessor implements BeanPropertyPostProcessor
 			});
 			ap.find(Swap.class, b.getter).forEach(x -> b.swap = swapSwap(x));
 			b.isUri |= ap.has(Uri.class, b.getter);
-			for (var ai : ap.find(MarshalledProp.class, b.getter))
+			for (var ai : ap.find(MarshalledProp.class, b.getter)) {
 				viewNames = addViews(viewNames, ai.inner().view());
+				if (ai.inner().nulls() != Nulls.NOT_SET)
+					perPropertyNulls = ai.inner().nulls();
+			}
 		}
 
 		if (nn(b.setter)) {
@@ -151,8 +161,11 @@ final class MarshalledPropertyPostProcessor implements BeanPropertyPostProcessor
 			});
 			ap.find(Swap.class, b.setter).forEach(x -> b.swap = swapSwap(x));
 			b.isUri |= ap.has(Uri.class, b.setter);
-			for (var ai : ap.find(MarshalledProp.class, b.setter))
+			for (var ai : ap.find(MarshalledProp.class, b.setter)) {
 				viewNames = addViews(viewNames, ai.inner().view());
+				if (ai.inner().nulls() != Nulls.NOT_SET)
+					perPropertyNulls = ai.inner().nulls();
+			}
 		}
 
 		if (viewNames != null)
@@ -191,6 +204,11 @@ final class MarshalledPropertyPostProcessor implements BeanPropertyPostProcessor
 		// Install schema-validation transforms (wraps any existing readTransform/writeTransform) when a
 		// PropertyValidatorFactory is on the classpath and the property carries at least one @Schema annotation.
 		installSchemaValidationTransforms(bc, b);
+
+		// Install null-coercion writeTransform (wraps any existing writeTransform).  Always installed so the
+		// context-level Parser.Builder#nulls(Nulls) default can take effect at runtime even when no per-property
+		// annotation is configured.  The wrap is a no-op for non-null values and the LEAVE policy.
+		installNullCoercionTransform(b, perPropertyNulls, propertyClass);
 	}
 
 	/**
@@ -269,6 +287,182 @@ final class MarshalledPropertyPostProcessor implements BeanPropertyPostProcessor
 		}
 	}
 
+	/** Sentinel marker used to cache "no reference instance could be built" for the {@link Nulls#DEFAULT} mode. */
+	private static final Object NO_DEFAULT_REFERENCE = new Object();
+	/** Cache key under which the per-{@link ClassMeta} reference instance is memoized for {@link Nulls#DEFAULT}. */
+	private static final String NULLS_DEFAULT_REFERENCE_CACHE_KEY = "MarshalledPropertyPostProcessor.nullsDefault.referenceInstance";
+
+	/**
+	 * Installs a null-coercion {@code writeTransform} on a {@link BeanPropertyMeta.Builder}.
+	 *
+	 * <p>
+	 * The installed wrap is invoked on every {@code BeanPropertyMeta.set} call but is a near-zero-cost no-op when the
+	 * post-swap value is non-null (the common case).  When the value is {@code null} (or {@link Optional#empty()} /
+	 * {@link OptionalInt}/{@link OptionalLong}/{@link OptionalDouble} in their empty form), the wrap consults the
+	 * effective {@link Nulls} mode (per-property &gt; session-level &gt; context-level &gt; {@link Nulls#LEAVE
+	 * LEAVE}) and substitutes one of the documented coerced values, or returns {@link BeanPropertyMeta#SKIP_VALUE} to
+	 * signal the bean-modeling layer to skip the set entirely.
+	 *
+	 * @param b The bean-property builder.
+	 * @param perPropertyNulls The per-property {@link Nulls} mode discovered on {@code @MarshalledProp}.
+	 * 	{@link Nulls#NOT_SET} means "no per-property override; fall through to the session/context default".
+	 * @param propertyClass The raw property class — used to compute {@link Nulls#EMPTY} / {@link Nulls#DEFAULT}
+	 * 	substitutes.
+	 */
+	@SuppressWarnings({
+		"java:S3776" // Cognitive complexity is acceptable; switch-over-modes is the clearest expression of the contract.
+	})
+	static void installNullCoercionTransform(BeanPropertyMeta.Builder b, Nulls perPropertyNulls, Class<?> propertyClass) {
+		var inner = b.writeTransform;
+		// Capture the full property ClassMeta so the Optional empty-default is nesting-aware (e.g.
+		// Optional<Optional<X>> → Optional.of(Optional.empty()) rather than a collapsed single-level
+		// Optional.empty()).  The raw propertyClass alone cannot express the nesting depth.
+		var propertyMeta = (b.rawTypeMeta instanceof ClassMeta<?> cm) ? cm : null;
+		b.writeTransform = (session, value) -> {
+			Object resolved = nn(inner) ? inner.apply(session, value) : value;
+			// Fast path — non-null, non-empty-Optional values bypass the policy lookup entirely.
+			if (resolved != null && ! isEmptyOptional(resolved))
+				return resolved;
+			var effective = resolveEffectiveNulls(perPropertyNulls, session);
+			return switch (effective) {
+				case LEAVE, NOT_SET -> coerceLeave(resolved, propertyClass, propertyMeta);
+				case EMPTY -> emptyValueFor(propertyClass, propertyMeta);
+				case DEFAULT -> defaultValueFor(b, propertyClass, propertyMeta, session);
+				case SKIP -> BeanPropertyMeta.SKIP_VALUE;
+			};
+		};
+	}
+
+	/**
+	 * Resolves the effective {@link Nulls} mode for a null-valued set.
+	 *
+	 * <p>
+	 * Precedence: per-property annotation &gt; session-level override &gt; parser-context default &gt;
+	 * {@link Nulls#LEAVE}.
+	 *
+	 * @param perPropertyNulls The per-property mode (may be {@link Nulls#NOT_SET}).
+	 * @param session The bean session — narrowed to {@link ParserSession} to read the session/context defaults.
+	 * @return The effective mode (never {@code null}; falls back to {@link Nulls#LEAVE}).
+	 */
+	private static Nulls resolveEffectiveNulls(Nulls perPropertyNulls, BeanSession session) {
+		if (perPropertyNulls != null && perPropertyNulls != Nulls.NOT_SET)
+			return perPropertyNulls;
+		if (session instanceof ParserSession ps) {
+			var ctx = ps.getNulls();
+			if (ctx != null && ctx != Nulls.NOT_SET)
+				return ctx;
+		}
+		return Nulls.LEAVE;
+	}
+
+	/**
+	 * {@link Nulls#LEAVE} coercion — passes through unchanged for non-Optional properties, but resolves
+	 * {@code null}/empty-Optional to {@link Optional#empty()} (or the matching primitive-Optional empty sentinel)
+	 * for {@link Optional}-typed properties so the shared Optional contract holds.
+	 */
+	private static Object coerceLeave(Object resolved, Class<?> propertyClass, ClassMeta<?> propertyMeta) {
+		var empty = emptyOptional(propertyClass, propertyMeta);
+		return empty != null ? empty : resolved;
+	}
+
+	/**
+	 * Returns the empty form for an {@link Optional}-flavored property, or {@code null} if the property is not an
+	 * optional type.
+	 *
+	 * <p>
+	 * For a regular {@link Optional}, this is nesting-aware: it delegates to {@link ClassMeta#getOptionalDefault()}
+	 * so {@code Optional<Optional<X>>} resolves to {@code Optional.of(Optional.empty())} (the shared contract:
+	 * present outer, empty inner) rather than a structure-collapsing single-level {@code Optional.empty()}.  When
+	 * the property metadata is unavailable, falls back to a single-level {@code Optional.empty()}.
+	 */
+	private static Object emptyOptional(Class<?> propertyClass, ClassMeta<?> propertyMeta) {
+		if (Optional.class.equals(propertyClass))
+			return (propertyMeta != null && propertyMeta.isOptional()) ? propertyMeta.getOptionalDefault() : Optional.empty();
+		if (OptionalInt.class.equals(propertyClass))
+			return OptionalInt.empty();
+		if (OptionalLong.class.equals(propertyClass))
+			return OptionalLong.empty();
+		if (OptionalDouble.class.equals(propertyClass))
+			return OptionalDouble.empty();
+		return null;
+	}
+
+	@SuppressWarnings({
+		"java:S3776" // Type-dispatch chain; splitting into per-type helpers would obscure the single-purpose intent.
+	})
+	private static Object emptyValueFor(Class<?> propertyClass, ClassMeta<?> propertyMeta) {
+		if (propertyClass == null || propertyClass == Object.class)
+			return null;
+		var emptyOpt = emptyOptional(propertyClass, propertyMeta);
+		if (emptyOpt != null)
+			return emptyOpt;
+		if (String.class.equals(propertyClass) || CharSequence.class.equals(propertyClass))
+			return "";
+		if (propertyClass.isArray())
+			return java.lang.reflect.Array.newInstance(propertyClass.getComponentType(), 0);
+		if (List.class.isAssignableFrom(propertyClass) || Collection.class.equals(propertyClass)
+				|| Iterable.class.equals(propertyClass))
+			return new ArrayList<>();
+		if (Set.class.isAssignableFrom(propertyClass))
+			return new LinkedHashSet<>();
+		if (Map.class.isAssignableFrom(propertyClass))
+			return new LinkedHashMap<>();
+		// Primitive defaults — Java auto-unbox handles boxed-type assignment.
+		if (propertyClass.isPrimitive()) {
+			if (boolean.class.equals(propertyClass)) return Boolean.FALSE;
+			if (byte.class.equals(propertyClass))    return Byte.valueOf((byte) 0);
+			if (short.class.equals(propertyClass))   return Short.valueOf((short) 0);
+			if (char.class.equals(propertyClass))    return Character.valueOf((char) 0);
+			if (int.class.equals(propertyClass))     return Integer.valueOf(0);
+			if (long.class.equals(propertyClass))    return Long.valueOf(0L);
+			if (float.class.equals(propertyClass))   return Float.valueOf(0f);
+			if (double.class.equals(propertyClass))  return Double.valueOf(0d);
+		}
+		// Other reference types — no canonical empty sentinel; fall back to null (the LEAVE behavior).
+		return null;
+	}
+
+	/**
+	 * {@link Nulls#DEFAULT} coercion — returns the property's value on a fresh no-arg-constructed reference
+	 * instance of the owning bean.  When no reference instance can be built (no accessible no-arg ctor, ctor
+	 * throws), falls back to {@link Nulls#LEAVE} behavior.
+	 */
+	@SuppressWarnings({
+		"java:S1166" // RuntimeException intentionally swallowed; the documented contract is "fall back to LEAVE".
+	})
+	private static Object defaultValueFor(BeanPropertyMeta.Builder b, Class<?> propertyClass, ClassMeta<?> propertyMeta, BeanSession session) {
+		if (! (session instanceof MarshallingSession ms))
+			return coerceLeave(null, propertyClass, propertyMeta);
+		var ownerInfo = owningClass(b);
+		if (ownerInfo == null)
+			return coerceLeave(null, propertyClass, propertyMeta);
+		var ownerMeta = ms.getClassMeta(ownerInfo.inner());
+		Optional<?> ref = ownerMeta.getProperty(NULLS_DEFAULT_REFERENCE_CACHE_KEY, cm -> {
+			var instance = BeanInstantiator.createOrNull(cm.inner());
+			return instance == null ? NO_DEFAULT_REFERENCE : instance;
+		});
+		var owner = ref.orElse(null);
+		if (owner == null || owner == NO_DEFAULT_REFERENCE)
+			return coerceLeave(null, propertyClass, propertyMeta);
+		try {
+			var beanMap = ms.toBeanMap(owner);
+			var prop = beanMap.getMeta().getPropertyMeta(b.name);
+			if (prop == null)
+				return coerceLeave(null, propertyClass, propertyMeta);
+			return prop.get(beanMap, b.name);
+		} catch (RuntimeException e) {
+			return coerceLeave(null, propertyClass, propertyMeta);
+		}
+	}
+
+	private static boolean isEmptyOptional(Object o) {
+		if (o instanceof Optional<?> opt) return opt.isEmpty();
+		if (o instanceof OptionalInt oi)  return oi.isEmpty();
+		if (o instanceof OptionalLong ol) return ol.isEmpty();
+		if (o instanceof OptionalDouble od) return od.isEmpty();
+		return false;
+	}
+
 	/**
 	 * Installs {@code @Schema}-driven validation transforms on a {@link BeanPropertyMeta.Builder}.
 	 *
@@ -453,6 +647,8 @@ final class MarshalledPropertyPostProcessor implements BeanPropertyPostProcessor
 			b.swap = enumSwap(mp.enumFormat(), propertyClass);
 		if (UUID.class.equals(propertyClass) && mp.uuidFormat() != UuidFormat.NOT_SET)
 			b.swap = uuidSwap(mp.uuidFormat());
+		if (BitSet.class.equals(propertyClass) && mp.bitSetFormat() != BitSetFormat.NOT_SET)
+			b.swap = bitSetSwap(mp.bitSetFormat());
 		if (isBigNumberType(propertyClass) && mp.bigNumberFormat() != BigNumberFormat.NOT_SET)
 			b.swap = bigNumberSwap(mp.bigNumberFormat(), propertyClass);
 		if (isBooleanType(propertyClass) && mp.booleanFormat() != BooleanFormat.NOT_SET)
@@ -493,6 +689,8 @@ final class MarshalledPropertyPostProcessor implements BeanPropertyPostProcessor
 			b.swap = enumSwap(m.enumFormat(), propertyClass);
 		else if (UUID.class.equals(propertyClass) && m.uuidFormat() != UuidFormat.NOT_SET)
 			b.swap = uuidSwap(m.uuidFormat());
+		else if (BitSet.class.equals(propertyClass) && m.bitSetFormat() != BitSetFormat.NOT_SET)
+			b.swap = bitSetSwap(m.bitSetFormat());
 		else if (isBigNumberType(propertyClass) && m.bigNumberFormat() != BigNumberFormat.NOT_SET)
 			b.swap = bigNumberSwap(m.bigNumberFormat(), propertyClass);
 		else if (isBooleanType(propertyClass) && m.booleanFormat() != BooleanFormat.NOT_SET)
@@ -542,6 +740,11 @@ final class MarshalledPropertyPostProcessor implements BeanPropertyPostProcessor
 			// default — UUID.toString()).  At STANDARD / NOT_SET, the serializer's natural String-conversion
 			// path already produces the right wire form.
 			b.swap = uuidSwap(bc.getUuidFormat());
+		else if (BitSet.class.equals(propertyClass))
+			// BitSet has no natural String round-trip (BitSet.toString() is not parseable), so install the
+			// context-level swap unconditionally — every configured format (INDICES default included)
+			// produces a parseable textual wire form.
+			b.swap = bitSetSwap(bc.getBitSetFormat());
 		else if (isBigNumberType(propertyClass) && bc.getBigNumberFormat() != BigNumberFormat.NUMBER && bc.getBigNumberFormat() != BigNumberFormat.NOT_SET)
 			// Install context-level swap only for STRING / AUTO.  At NUMBER / NOT_SET, BigInteger /
 			// BigDecimal are already emitted as bare numeric tokens by every text serializer.
@@ -877,6 +1080,20 @@ final class MarshalledPropertyPostProcessor implements BeanPropertyPostProcessor
 		};
 	}
 
+	private static ObjectSwap<?,?> bitSetSwap(BitSetFormat format) {
+		return new ObjectSwap<BitSet,String>(BitSet.class, String.class) {
+			@Override /* ObjectSwap */
+			public String swap(MarshallingSession session, BitSet o) {
+				return BitSetFormat.format(o, format);
+			}
+
+			@Override /* ObjectSwap */
+			public BitSet unswap(MarshallingSession session, String o, ClassMeta<?> hint) {
+				return BitSetFormat.parse(o, format);
+			}
+		};
+	}
+
 	@SuppressWarnings({
 		"java:S3776" // Cognitive-complexity threshold is advisory; consolidated BigInteger/BigDecimal dispatch keeps JIT inlining effective per AGENTS.md policy.
 	})
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingContext.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingContext.java
index 8c063de659..dc450d5eb0 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingContext.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingContext.java
@@ -213,6 +213,7 @@ public class MarshallingContext extends Context implements ConversionFinder, Bea
 	private static final String PROP_binaryFormat = "binaryFormat";
 	private static final String PROP_enumFormat = "enumFormat";
 	private static final String PROP_uuidFormat = "uuidFormat";
+	private static final String PROP_bitSetFormat = "bitSetFormat";
 	private static final String PROP_bigNumberFormat = "bigNumberFormat";
 	private static final String PROP_booleanFormat = "booleanFormat";
 	private static final String PROP_floatFormat = "floatFormat";
@@ -290,6 +291,7 @@ public class MarshallingContext extends Context implements ConversionFinder, Bea
 		private BinaryFormat binaryFormat;
 		private EnumFormat enumFormat;
 		private UuidFormat uuidFormat;
+		private BitSetFormat bitSetFormat;
 		private BigNumberFormat bigNumberFormat;
 		private BooleanFormat booleanFormat;
 		private FloatFormat floatFormat;
@@ -348,6 +350,7 @@ public class MarshallingContext extends Context implements ConversionFinder, Bea
 			binaryFormat = env("MarshallingContext.binaryFormat", BinaryFormat.NOT_SET);
 			enumFormat = env("MarshallingContext.enumFormat", EnumFormat.TO_STRING);
 			uuidFormat = env("MarshallingContext.uuidFormat", UuidFormat.STANDARD);
+			bitSetFormat = env("MarshallingContext.bitSetFormat", BitSetFormat.INDICES);
 			bigNumberFormat = env("MarshallingContext.bigNumberFormat", BigNumberFormat.NUMBER);
 			booleanFormat = env("MarshallingContext.booleanFormat", BooleanFormat.TRUE_FALSE);
 			floatFormat = env("MarshallingContext.floatFormat", FloatFormat.NaN_AS_NULL);
@@ -403,6 +406,7 @@ public class MarshallingContext extends Context implements ConversionFinder, Bea
 			binaryFormat = copyFrom.binaryFormat;
 			enumFormat = copyFrom.enumFormat;
 			uuidFormat = copyFrom.uuidFormat;
+			bitSetFormat = copyFrom.bitSetFormat;
 			bigNumberFormat = copyFrom.bigNumberFormat;
 			booleanFormat = copyFrom.booleanFormat;
 			floatFormat = copyFrom.floatFormat;
@@ -461,6 +465,7 @@ public class MarshallingContext extends Context implements ConversionFinder, Bea
 			binaryFormat = copyFrom.binaryFormat;
 			enumFormat = copyFrom.enumFormat;
 			uuidFormat = copyFrom.uuidFormat;
+			bitSetFormat = copyFrom.bitSetFormat;
 			bigNumberFormat = copyFrom.bigNumberFormat;
 			booleanFormat = copyFrom.booleanFormat;
 			floatFormat = copyFrom.floatFormat;
@@ -2355,6 +2360,7 @@ public class MarshallingContext extends Context implements ConversionFinder, Bea
 				binaryFormat,
 				enumFormat,
 				uuidFormat,
+				bitSetFormat,
 				bigNumberFormat,
 				booleanFormat,
 				floatFormat,
@@ -3635,6 +3641,20 @@ public class MarshallingContext extends Context implements ConversionFinder, Bea
 			return this;
 		}
 
+		/**
+		 * BitSet wire format.
+		 *
+		 * <p>
+		 * Controls how {@link BitSet} values are written to text-based wire formats.
+		 *
+		 * @param value The value for this setting.
+		 * @return This object.
+		 */
+		public Builder bitSetFormat(BitSetFormat value) {
+			bitSetFormat = value == null ? BitSetFormat.INDICES : value;
+			return this;
+		}
+
 		/**
 		 * Big-number wire format.
 		 *
@@ -4045,6 +4065,7 @@ public class MarshallingContext extends Context implements ConversionFinder, Bea
 	private final BinaryFormat binaryFormat;
 	private final EnumFormat enumFormat;
 	private final UuidFormat uuidFormat;
+	private final BitSetFormat bitSetFormat;
 	private final BigNumberFormat bigNumberFormat;
 	private final BooleanFormat booleanFormat;
 	private final FloatFormat floatFormat;
@@ -4103,6 +4124,7 @@ public class MarshallingContext extends Context implements ConversionFinder, Bea
 		binaryFormat = builder.binaryFormat;
 		enumFormat = builder.enumFormat;
 		uuidFormat = builder.uuidFormat;
+		bitSetFormat = builder.bitSetFormat;
 		bigNumberFormat = builder.bigNumberFormat;
 		booleanFormat = builder.booleanFormat;
 		floatFormat = builder.floatFormat;
@@ -5309,6 +5331,13 @@ public class MarshallingContext extends Context implements ConversionFinder, Bea
 	 */
 	public final UuidFormat getUuidFormat() { return uuidFormat; }
 
+	/**
+	 * BitSet wire format.
+	 *
+	 * @return The BitSet wire format.
+	 */
+	public final BitSetFormat getBitSetFormat() { return bitSetFormat; }
+
 	/**
 	 * Big-number wire format.
 	 *
@@ -5457,13 +5486,14 @@ public class MarshallingContext extends Context implements ConversionFinder, Bea
 			.a(PROP_binaryFormat, binaryFormat)
 			.a(PROP_enumFormat, enumFormat)
 			.a(PROP_uuidFormat, uuidFormat)
+			.a(PROP_bitSetFormat, bitSetFormat)
 			.a(PROP_bigNumberFormat, bigNumberFormat)
 			.a(PROP_booleanFormat, booleanFormat)
 			.a(PROP_floatFormat, floatFormat)
 			.a(PROP_currencyFormat, currencyFormat)
-		.a(PROP_classFormat, classFormat)
-		.a(PROP_classLoader, classLoader)
-		.a(PROP_useInterfaceProxies, useInterfaceProxies)
+			.a(PROP_classFormat, classFormat)
+			.a(PROP_classLoader, classLoader)
+			.a(PROP_useInterfaceProxies, useInterfaceProxies)
 			.a(PROP_useJavaBeanIntrospector, useJavaBeanIntrospector)
 			.a(PROP_validateSchema, validateSchema);
 	}
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingContextable.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingContextable.java
index bb103a06c4..5c4faa2270 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingContextable.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingContextable.java
@@ -2960,6 +2960,17 @@ public abstract class MarshallingContextable extends Context {
 			return self();
 		}
 
+		/**
+		 * BitSet wire format.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 */
+		public SELF bitSetFormat(BitSetFormat value) {
+			bcBuilder.bitSetFormat(value);
+			return self();
+		}
+
 		/**
 		 * Big-number wire format.
 		 *
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingTraverseSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingTraverseSession.java
index cef33ade8a..615419cebf 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingTraverseSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/MarshallingTraverseSession.java
@@ -245,6 +245,12 @@ public class MarshallingTraverseSession extends MarshallingSession {
 			return null;
 		if (o instanceof Optional<?> o2)
 			return getOptionalValue(o2.orElse(null));
+		if (o instanceof OptionalInt o2)
+			return o2.isPresent() ? Integer.valueOf(o2.getAsInt()) : null;
+		if (o instanceof OptionalLong o2)
+			return o2.isPresent() ? Long.valueOf(o2.getAsLong()) : null;
+		if (o instanceof OptionalDouble o2)
+			return o2.isPresent() ? Double.valueOf(o2.getAsDouble()) : null;
 		return o;
 	}
 
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/Nulls.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/Nulls.java
new file mode 100644
index 0000000000..fce8a3bba2
--- /dev/null
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/Nulls.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.marshall;
+
+import java.util.*;
+
+/**
+ * Per-property null-coercion modes consulted by parsers when an explicit {@code null} (or absent
+ * {@link Optional}) is encountered on the wire.
+ *
+ * <p>
+ * Juneau's analog of Jackson's {@code com.fasterxml.jackson.annotation.Nulls}.  The selected mode
+ * decides what the parser does <em>at the moment of setting</em> the bean property — it does not
+ * affect serialization.
+ *
+ * <p>
+ * Precedence:
+ * <ol>
+ * 	<li>Per-property {@link org.apache.juneau.marshall.MarshalledProp#nulls() @MarshalledProp(nulls=…)}.
+ * 	<li>Context-level default on {@link org.apache.juneau.marshall.parser.Parser.Builder#nulls(Nulls)}.
+ * 	<li>{@link #LEAVE} when neither is set.
+ * </ol>
+ *
+ * <h5 class='section'>Optional contract:</h5>
+ * <p>
+ * For an {@link Optional}-typed property, {@link #EMPTY} and {@link #DEFAULT} both resolve to
+ * {@link Optional#empty()} — never a bare {@code null} inside the {@link Optional}.  The same rule
+ * applies to {@link OptionalInt}, {@link OptionalLong}, and {@link OptionalDouble}.
+ *
+ * @since 10.0.0
+ */
+public enum Nulls {
+
+	/** Sentinel meaning "no value configured" — falls through to the next-higher precedence level. */
+	NOT_SET,
+
+	/**
+	 * Pass the {@code null} value through to the setter unchanged.
+	 *
+	 * <p>
+	 * For an {@link Optional}-typed property this resolves to {@link Optional#empty()} per the shared
+	 * Optional contract — never a bare {@code null} inside the {@link Optional}.
+	 *
+	 * <p>
+	 * This is the default behavior when no policy is configured.
+	 */
+	LEAVE,
+
+	/**
+	 * Substitute the type's "empty" value:
+	 * <ul>
+	 * 	<li>{@link String} → {@code ""}
+	 * 	<li>{@link java.util.Collection} → empty collection (preserves declared type when possible)
+	 * 	<li>{@link java.util.Map} → empty map
+	 * 	<li>Array → zero-length array of the declared element type
+	 * 	<li>{@link Optional} / {@link OptionalInt} / {@link OptionalLong} / {@link OptionalDouble} →
+	 * 		the corresponding {@code empty()} sentinel
+	 * 	<li>Primitive — the Java primitive default ({@code 0}, {@code false}, {@code (char)0})
+	 * 	<li>Other reference types — falls back to {@link #LEAVE}
+	 * </ul>
+	 */
+	EMPTY,
+
+	/**
+	 * Substitute the bean-constructed default for this property — the value the property holds on a
+	 * fresh no-arg-constructed instance of the bean.
+	 *
+	 * <p>
+	 * The reference instance is built once per bean class (and cached on its {@code ClassMeta}).  When
+	 * no reference instance can be built (no accessible no-arg ctor / constructor throws), this mode
+	 * silently falls back to {@link #LEAVE}.
+	 */
+	DEFAULT,
+
+	/**
+	 * Do not call the setter at all.
+	 *
+	 * <p>
+	 * Any value already present on the bean (typically the bean's own field-initializer value) is
+	 * preserved.  This is useful for properties whose absence on the wire should be treated as "no
+	 * change" rather than "reset to null".
+	 */
+	SKIP;
+}
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/hjson/HjsonSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/hjson/HjsonSerializerSession.java
index 3e964feaa3..954a6ff264 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/hjson/HjsonSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/hjson/HjsonSerializerSession.java
@@ -144,7 +144,7 @@ public class HjsonSerializerSession extends WriterSerializerSession implements R
 			var cMeta = (ClassMeta<?>) pMeta.getBeanInfo();
 			if (nn(thrown))
 				onBeanGetterException(pMeta, thrown);
-			if (canIgnoreValue(cMeta, key, value))
+			if (canIgnoreValue(pMeta, key, value))
 				return;
 			writeKey(out, key, i, first);
 			serializeAnything(out, value, cMeta, key, pMeta);
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/hocon/HoconSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/hocon/HoconSerializerSession.java
index 5fcbe6abe9..f16212fee5 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/hocon/HoconSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/hocon/HoconSerializerSession.java
@@ -146,7 +146,7 @@ public class HoconSerializerSession extends WriterSerializerSession implements R
 			var cMeta = (ClassMeta<?>) pMeta.getBeanInfo();
 			if (nn(thrown))
 				onBeanGetterException(pMeta, thrown);
-			if (canIgnoreValue(cMeta, key, value))
+			if (canIgnoreValue(pMeta, key, value))
 				return;
 			var isObject = cMeta.isBean() || cMeta.isMap();
 			writeKeyPrefix(out, key, i, first);
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/html/HtmlSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/html/HtmlSerializerSession.java
index 1f5d358437..1d05f251c1 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/html/HtmlSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/html/HtmlSerializerSession.java
@@ -456,7 +456,7 @@ public class HtmlSerializerSession extends XmlSerializerSession {
 			if (nn(thrown))
 				onBeanGetterException(pMeta, thrown);
 
-			if (canIgnoreValue(cMeta, key, value))
+			if (canIgnoreValue(pMeta, key, value))
 				return;
 
 			String link = null;
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniSerializerSession.java
index ec3c5bcc58..a63f56c3ac 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/ini/IniSerializerSession.java
@@ -134,7 +134,7 @@ public class IniSerializerSession extends WriterSerializerSession implements Rec
 			if (nn(thrown))
 				onBeanGetterException(pMeta, thrown);
 			var cMeta = (ClassMeta<?>) pMeta.getBeanInfo();
-			if (canIgnoreValue(cMeta, key, value))
+			if (canIgnoreValue(pMeta, key, value))
 				return;
 			var aType = value == null ? cMeta : getClassMetaForObject(value, cMeta);
 			var iniMeta = ctx.getIniBeanPropertyMeta(pMeta);
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/jcs/JcsSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/jcs/JcsSerializerSession.java
index a759a56ca5..a5ca84d632 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/jcs/JcsSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/jcs/JcsSerializerSession.java
@@ -333,10 +333,9 @@ public class JcsSerializerSession extends JsonSerializerSession {
 		Predicate<Object> checkNull = x -> isKeepNullProperties() || nn(x);
 
 		m.forEachValue(checkNull, (pMeta, key, value, thrown) -> {
-			var cMeta = (ClassMeta<?>) pMeta.getBeanInfo();
 			if (nn(thrown))
 				onBeanGetterException(pMeta, thrown);
-			if (canIgnoreValue(cMeta, key, value))
+			if (canIgnoreValue(pMeta, key, value))
 				return;
 			entries.add(new BeanProp(pMeta, key, value));
 		});
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/json/JsonSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/json/JsonSerializerSession.java
index 7944394e74..39beff3ab5 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/json/JsonSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/json/JsonSerializerSession.java
@@ -168,7 +168,7 @@ public class JsonSerializerSession extends WriterSerializerSession implements To
 			if (nn(thrown))
 				onBeanGetterException(pMeta, thrown);
 
-			if (canIgnoreValue(cMeta, key, value))
+			if (canIgnoreValue(pMeta, key, value))
 				return;
 
 			addComma.ifSet(() -> out.append(',').smi(i)).set();
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/Parser.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/Parser.java
index f83ad48cd0..784cbd31e6 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/Parser.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/Parser.java
@@ -148,6 +148,7 @@ public class Parser extends MarshallingContextable {
 	private static final String PROP_autoCloseStreams = "autoCloseStreams";
 	private static final String PROP_debugOutputLines = "debugOutputLines";
 	private static final String PROP_listener = "listener";
+	private static final String PROP_nulls = "nulls";
 	private static final String PROP_trimStrings = "trimStrings";
 	private static final String PROP_unbuffered = "unbuffered";
 
@@ -165,6 +166,7 @@ public class Parser extends MarshallingContextable {
 		private Class<? extends ParserListener> listener;
 		private int debugOutputLines;
 		private String consumes;
+		private Nulls nulls;
 
 		/**
 		 * Constructor, default settings.
@@ -176,6 +178,7 @@ public class Parser extends MarshallingContextable {
 			debugOutputLines = env("Parser.debugOutputLines", 5);
 			listener = null;
 			consumes = null;
+			nulls = Nulls.NOT_SET;
 		}
 
 		/**
@@ -191,6 +194,7 @@ public class Parser extends MarshallingContextable {
 			debugOutputLines = copyFrom.debugOutputLines;
 			listener = copyFrom.listener;
 			consumes = copyFrom.consumes;
+			nulls = copyFrom.nulls;
 		}
 
 		/**
@@ -206,6 +210,7 @@ public class Parser extends MarshallingContextable {
 			debugOutputLines = copyFrom.debugOutputLines;
 			listener = copyFrom.listener;
 			consumes = copyFrom.consumes;
+			nulls = copyFrom.nulls;
 		}
 
 		/**
@@ -326,7 +331,8 @@ public class Parser extends MarshallingContextable {
 				unbuffered,
 				debugOutputLines,
 				listener,
-				consumes
+				consumes,
+				nulls
 			);
 			// @formatter:on
 		}
@@ -387,6 +393,43 @@ public class Parser extends MarshallingContextable {
 			return self();
 		}
 
+		/**
+		 * Sets the default per-property null-coercion policy applied during parsing.
+		 *
+		 * <p>
+		 * Juneau's analog of Jackson's
+		 * <c>ObjectMapper.setDefaultSetterInfo(JsonSetter.Value.forValueNulls(...))</c>.
+		 * Applied as the fallback when no per-property
+		 * {@link org.apache.juneau.marshall.MarshalledProp#nulls() @MarshalledProp(nulls=…)} is configured.
+		 *
+		 * <h5 class='section'>Example:</h5>
+		 * <p class='bjava'>
+		 * 	<jc>// Substitute the empty value for any property whose wire value is null.</jc>
+		 * 	ReaderParser <jv>parser</jv> = JsonParser
+		 * 		.<jsm>create</jsm>()
+		 * 		.nulls(Nulls.<jsf>EMPTY</jsf>)
+		 * 		.build();
+		 * </p>
+		 *
+		 * @param value The new value for this property.
+		 * 	<br>{@code null} or {@link Nulls#NOT_SET} disables the default (each property falls back to
+		 * 	{@link Nulls#LEAVE}).
+		 * @return This object.
+		 * @since 10.0.0
+		 */
+		public SELF nulls(Nulls value) {
+			nulls = value == null ? Nulls.NOT_SET : value;
+			return self();
+		}
+
+		/**
+		 * Returns the current context-level {@link Nulls} default configured on this builder.
+		 *
+		 * @return The current value (never <jk>null</jk>; {@link Nulls#NOT_SET} when unconfigured).
+		 * @since 10.0.0
+		 */
+		public Nulls getNulls() { return nulls == null ? Nulls.NOT_SET : nulls; }
+
 		/**
 		 * Trim parsed strings.
 		 *
@@ -556,6 +599,7 @@ public class Parser extends MarshallingContextable {
 	protected final int debugOutputLines;
 	protected final Class<? extends ParserListener> listener;
 	protected final String consumes;
+	protected final Nulls nulls;
 	private final List<MediaType> consumesArray;
 
 	/**
@@ -572,6 +616,7 @@ public class Parser extends MarshallingContextable {
 		listener = builder.listener;
 		trimStrings = builder.trimStrings;
 		unbuffered = builder.unbuffered;
+		nulls = builder.nulls == null ? Nulls.NOT_SET : builder.nulls;
 
 	String[] consumesParts = splita(nn(consumes) ? consumes : "");
 	List<MediaType> consumesList = new ArrayList<>();
@@ -980,6 +1025,16 @@ public class Parser extends MarshallingContextable {
 	 */
 	protected final boolean isTrimStrings() { return trimStrings; }
 
+	/**
+	 * Returns the context-level {@link Nulls} default consulted at parse-time when no per-property
+	 * {@link org.apache.juneau.marshall.MarshalledProp#nulls() @MarshalledProp(nulls=…)} is configured.
+	 *
+	 * @return The current value (never <jk>null</jk>; {@link Nulls#NOT_SET} when unconfigured).
+	 * @see Parser.Builder#nulls(Nulls)
+	 * @since 10.0.0
+	 */
+	public final Nulls getNulls() { return nulls; }
+
 	/**
 	 * Unbuffered.
 	 *
@@ -995,6 +1050,7 @@ public class Parser extends MarshallingContextable {
 			.a(PROP_autoCloseStreams, autoCloseStreams)
 			.a(PROP_debugOutputLines, debugOutputLines)
 			.a(PROP_listener, listener)
+			.a(PROP_nulls, nulls)
 			.a(PROP_trimStrings, trimStrings)
 			.a(PROP_unbuffered, unbuffered);
 	}
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserConfig.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserConfig.java
index f50ae97b68..c87b3d6971 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserConfig.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserConfig.java
@@ -217,6 +217,36 @@ public @interface ParserConfig {
 	 */
 	String trimStrings() default "";
 
+	/**
+	 * Default per-property null-coercion policy applied during parsing.
+	 *
+	 * <p>
+	 * Juneau's analog of Jackson's <c>JsonSetter.Value.forValueNulls(...)</c> at the
+	 * <c>ObjectMapper</c> level.  Applied as the fallback when no per-property
+	 * {@link org.apache.juneau.marshall.MarshalledProp#nulls() @MarshalledProp(nulls=…)} is configured.
+	 *
+	 * <ul class='values'>
+	 * 	<li><js>"LEAVE"</js> (default) — pass {@code null} through unchanged.
+	 * 	<li><js>"EMPTY"</js> — substitute the type's empty value (empty {@link String}/{@link java.util.Collection}/
+	 * 		{@link java.util.Map}, primitive default for primitives, {@code Optional.empty()} for {@link java.util.Optional}).
+	 * 	<li><js>"DEFAULT"</js> — substitute the bean-constructed default for the property.
+	 * 	<li><js>"SKIP"</js> — do not call the setter at all.
+	 * </ul>
+	 *
+	 * <h5 class='section'>Notes:</h5><ul>
+	 * 	<li class='note'>
+	 * 		Supports <a class="doclink" href="https://juneau.apache.org/docs/topics/DefaultVarResolver">VarResolver.DEFAULT</a> (e.g. <js>"$C{myConfigVar}"</js>).
+	 * </ul>
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='jm'>{@link org.apache.juneau.marshall.parser.Parser.Builder#nulls(org.apache.juneau.marshall.Nulls)}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 10.0.0
+	 */
+	String nulls() default "";
+
 	/**
 	 * Unbuffered.
 	 *
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserConfigAnnotation.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserConfigAnnotation.java
index 32f4631d0c..e0787f1c69 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserConfigAnnotation.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserConfigAnnotation.java
@@ -86,6 +86,7 @@ public class ParserConfigAnnotation {
 			type(a.listener()).ifPresent(b::listener);
 			bool(a.trimStrings()).ifPresent(b::trimStrings);
 			bool(a.unbuffered()).ifPresent(b::unbuffered);
+			string(a.nulls()).map(Nulls::valueOf).ifPresent(b::nulls);
 		}
 	}
 
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserSession.java
index a491bac3a6..147be6ed1d 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserSession.java
@@ -69,10 +69,12 @@ public class ParserSession extends MarshallingSession {
 	private static final String PROP_outer = "outer";
 	private static final String PROP_schema = "schema";
 	private static final String PROP_trimStrings = "trimStrings";
+	private static final String PROP_nulls = "nulls";
 	private static final String PROP_ParserSession_javaMethod = "ParserSession.javaMethod";
 	private static final String PROP_ParserSession_outer = "ParserSession.outer";
 	private static final String PROP_ParserSession_schema = "ParserSession.schema";
 	private static final String PROP_ParserSession_trimStrings = "ParserSession.trimStrings";
+	private static final String PROP_ParserSession_nulls = "ParserSession.nulls";
 
 	// Argument name constants for assertArgNotNull
 	private static final String ARG_ctx = "ctx";
@@ -89,6 +91,7 @@ public class ParserSession extends MarshallingSession {
 		private Method javaMethod;
 		private Object outer;
 		private boolean trimStrings;
+		private Nulls nulls;
 		private Parser ctx;
 
 		/**
@@ -102,6 +105,7 @@ public class ParserSession extends MarshallingSession {
 			this.ctx = ctx;
 			mediaTypeDefault(ctx.getPrimaryMediaType());
 			trimStrings = ctx.isTrimStrings();
+			nulls = ctx.getNulls();
 		}
 
 		@Override
@@ -151,6 +155,8 @@ public class ParserSession extends MarshallingSession {
 					return schema(cvt(value, HttpPartSchema.class));
 				case PROP_trimStrings, PROP_ParserSession_trimStrings:
 					return trimStrings(cvt(value, Boolean.class));
+				case PROP_nulls, PROP_ParserSession_nulls:
+					return nulls(cvt(value, Nulls.class));
 				default:
 					super.property(key, value);
 					return self();
@@ -168,6 +174,19 @@ public class ParserSession extends MarshallingSession {
 			return self();
 		}
 
+		/**
+		 * Per-session override for the parser's null-coercion policy.
+		 *
+		 * @param value The new value for this setting.  {@code null} is treated as {@link Nulls#NOT_SET}.
+		 * @return This object.
+		 * @see Parser.Builder#nulls(Nulls)
+		 * @since 10.0.0
+		 */
+		public SELF nulls(Nulls value) {
+			nulls = value == null ? Nulls.NOT_SET : value;
+			return self();
+		}
+
 		/**
 		 * HTTP-part schema.
 		 *
@@ -262,6 +281,7 @@ public class ParserSession extends MarshallingSession {
 	private final Method javaMethod;
 	private final Object outer;
 	private final boolean trimStrings;
+	private final Nulls nulls;
 	private final Parser ctx;
 	private final ParserListener listener;
 	private final Deque<StringBuilder> sbStack;
@@ -282,6 +302,7 @@ public class ParserSession extends MarshallingSession {
 		outer = builder.outer;
 		schema = builder.schema;
 		trimStrings = builder.trimStrings;
+		nulls = builder.nulls == null ? Nulls.NOT_SET : builder.nulls;
 		listener = BeanInstantiator.createOrNull(ctx.getListener());
 		sbStack = new ArrayDeque<>();
 	}
@@ -737,7 +758,13 @@ public class ParserSession extends MarshallingSession {
 		if (BeanSupplier.class.isAssignableFrom(type.inner()) && !BeanChannel.class.isAssignableFrom(type.inner()))
 			throw new ParseException(this, "BeanSupplier cannot be used as a parser target. Use BeanConsumer or BeanChannel for round-trip support.");
 		try {
-			return doParse(pipe, type);
+			var r = doParse(pipe, type);
+			// Primitive optionals (OptionalInt/Long/Double) are parsed as first-class optionals — the format
+			// parsers wrap the element in a generic Optional, so coerce the top-level result to the matching
+			// primitive-optional type (mirrors the Optional<T> contract; absent/null already resolved to empty).
+			if (type.isPrimitiveOptional() && ! type.inner().isInstance(r))
+				return convertToType(r, type);
+			return r;
 		} catch (ParseException | IOException e) {
 			throw e;
 		} catch (@SuppressWarnings("unused") StackOverflowError e) {
@@ -1145,6 +1172,16 @@ public class ParserSession extends MarshallingSession {
 	 */
 	protected final boolean isTrimStrings() { return trimStrings; }
 
+	/**
+	 * Returns the effective {@link Nulls} default for this session — the per-session override (set via
+	 * {@link ParserSession.Builder#nulls(Nulls)}) when present, otherwise the parser context's
+	 * {@link Parser#getNulls()} value.
+	 *
+	 * @return The current session-level value (never <jk>null</jk>; {@link Nulls#NOT_SET} when unconfigured).
+	 * @since 10.0.0
+	 */
+	public final Nulls getNulls() { return nulls; }
+
 	/**
 	 * Unbuffered.
 	 *
@@ -1322,7 +1359,8 @@ public class ParserSession extends MarshallingSession {
 			.a(PROP_javaMethod, javaMethod)
 			.a(PROP_listener, listener)
 			.a(PROP_outer, outer)
-			.a(PROP_trimStrings, trimStrings);
+			.a(PROP_trimStrings, trimStrings)
+			.a(PROP_nulls, nulls);
 	}
 
 	/**
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/proto/ProtoSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/proto/ProtoSerializerSession.java
index 7d3aea39ff..4cb03de5c8 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/proto/ProtoSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/proto/ProtoSerializerSession.java
@@ -261,7 +261,7 @@ public class ProtoSerializerSession extends WriterSerializerSession implements R
 			if (value == null)
 				return;
 			var cMeta = (ClassMeta<?>) pMeta.getBeanInfo();
-			if (canIgnoreValue(cMeta, key, value))
+			if (canIgnoreValue(pMeta, key, value))
 				return;
 			var protoPMeta = ctx.getProtoBeanPropertyMeta(pMeta);
 			if (nn(protoPMeta) && !protoPMeta.getComment().isEmpty())
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/Serializer.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/Serializer.java
index c6580e72ce..a1fe0ed5b0 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/Serializer.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/Serializer.java
@@ -82,6 +82,7 @@ public class Serializer extends MarshallingTraverseContext {
 	private static final String PROP_addRootType = "addRootType";
 	private static final String PROP_keepNullProperties = "keepNullProperties";
 	private static final String PROP_listener = "listener";
+	private static final String PROP_nonDefault = "nonDefault";
 	private static final String PROP_sortCollections = "sortCollections";
 	private static final String PROP_sortMaps = "sortMaps";
 	private static final String PROP_trimEmptyCollections = "trimEmptyCollections";
@@ -105,6 +106,7 @@ public class Serializer extends MarshallingTraverseContext {
 		private boolean addBeanTypes;
 		private boolean addRootType;
 		private boolean keepNullProperties;
+		private boolean nonDefault;
 		private boolean sortCollections;
 		private boolean sortMaps;
 		private boolean trimEmptyCollections;
@@ -126,6 +128,7 @@ public class Serializer extends MarshallingTraverseContext {
 			addBeanTypes = env("Serializer.addBeanTypes", false);
 			addRootType = env("Serializer.addRootType", false);
 			keepNullProperties = env("Serializer.keepNullProperties", false);
+			nonDefault = env("Serializer.nonDefault", false);
 			sortCollections = env("Serializer.sortCollections", false);
 			sortMaps = env("Serializer.sortMaps", false);
 			trimEmptyCollections = env("Serializer.trimEmptyCollections", false);
@@ -150,6 +153,7 @@ public class Serializer extends MarshallingTraverseContext {
 			addBeanTypes = copyFrom.addBeanTypes;
 			addRootType = copyFrom.addRootType;
 			keepNullProperties = copyFrom.keepNullProperties;
+			nonDefault = copyFrom.nonDefault;
 			sortCollections = copyFrom.sortCollections;
 			sortMaps = copyFrom.sortMaps;
 			trimEmptyCollections = copyFrom.trimEmptyCollections;
@@ -174,6 +178,7 @@ public class Serializer extends MarshallingTraverseContext {
 			addBeanTypes = copyFrom.addBeanTypes;
 			addRootType = copyFrom.addRootType;
 			keepNullProperties = copyFrom.keepNullProperties;
+			nonDefault = copyFrom.nonDefault;
 			sortCollections = copyFrom.sortCollections;
 			sortMaps = copyFrom.sortMaps;
 			trimEmptyCollections = copyFrom.trimEmptyCollections;
@@ -366,6 +371,7 @@ public class Serializer extends MarshallingTraverseContext {
 				addBeanTypes,
 				addRootType,
 				keepNullProperties,
+				nonDefault,
 				sortCollections,
 				sortMaps,
 				trimEmptyCollections,
@@ -429,6 +435,87 @@ public class Serializer extends MarshallingTraverseContext {
 			return self();
 		}
 
+		/**
+		 * Don't serialize bean properties whose values equal the default for their type.
+		 *
+		 * <p>
+		 * When enabled, bean properties whose current value equals the type's natural default are omitted from
+		 * the serialized output. This is the Juneau analog of Jackson's <c>JsonInclude.Include.NON_DEFAULT</c>
+		 * inclusion mode.
+		 *
+		 * <p>
+		 * The notion of "default" depends on the property type:
+		 * <ul class='spaced-list'>
+		 * 	<li>Primitives and primitive wrappers (<code><jk>int</jk></code>, <code>Integer</code>,
+		 * 		<code><jk>boolean</jk></code>, <code>Boolean</code>, etc.) use the Java type default
+		 * 		(<c>0</c>, <c>false</c>, <code>'\0'</code>, <jk>null</jk>).
+		 * 	<li>Bean properties on bean-typed property values use a <i>reference instance</i> of the bean type —
+		 * 		instantiated once via the no-arg constructor and cached on the {@link ClassMeta} — and the
+		 * 		property is omitted when its current value equals that reference's value.
+		 * 	<li>When no reference instance can be built (no accessible no-arg constructor, or instantiation
+		 * 		throws), <c>nonDefault</c> is silently skipped for that bean — the property is emitted normally
+		 * 		rather than failing the serialize.
+		 * </ul>
+		 *
+		 * <p>
+		 * <b>Equality semantics:</b> value-equals-default uses {@link Objects#equals}, with numeric values
+		 * compared by mathematical value (BigDecimal.compareTo-style) so <c>1.0</c> equals <c>1</c>,
+		 * <c>0.0</c> equals <c>-0.0</c>, and <code>BigDecimal(<js>"1.0"</js>)</code> equals
+		 * <code>BigDecimal(<js>"1"</js>)</code>.
+		 *
+		 * <p>
+		 * <b>Precedence:</b> the serialize-side inclusion knobs short-circuit in a fixed order — first "omit" wins:
+		 * <ol>
+		 * 	<li><jk>null</jk> values (including {@link java.util.Optional#empty()}) are governed by
+		 * 		{@link #keepNullProperties()}.
+		 * 	<li>Non-<jk>null</jk> string values are normalized by {@link #trimStrings()}.
+		 * 	<li>Empty collections / arrays / maps are omitted by {@link #trimEmptyCollections()} /
+		 * 		{@link #trimEmptyMaps()}.
+		 * 	<li>Surviving values that equal their type's default are omitted by <c>nonDefault</c>.
+		 * </ol>
+		 * <jk>null</jk> is resolved by <c>keepNullProperties</c>, not treated as a <c>nonDefault</c> case.
+		 *
+		 * <h5 class='section'>Example:</h5>
+		 * <p class='bjava'>
+		 * 	<jc>// Create a serializer that omits default-valued properties.</jc>
+		 * 	WriterSerializer <jv>serializer</jv> = JsonSerializer
+		 * 		.<jsm>create</jsm>()
+		 * 		.nonDefault()
+		 * 		.build();
+		 *
+		 * 	<jc>// Our bean to serialize.</jc>
+		 * 	<jk>public class</jk> MyBean {
+		 * 		<jk>public int</jk> <jf>count</jf>;            <jc>// Default 0; omitted unless set to non-zero.</jc>
+		 * 		<jk>public</jk> String <jf>name</jf> = <js>"x"</js>; <jc>// Bean-default is "x"; only omitted when value equals "x".</jc>
+		 * 	}
+		 *
+		 * 	<jc>// Produces "{}" because both fields equal their defaults.</jc>
+		 * 	String <jv>json</jv> = <jv>serializer</jv>.serialize(<jk>new</jk> MyBean());
+		 * </p>
+		 *
+		 * <h5 class='section'>See Also:</h5><ul>
+		 * 	<li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/InclusionAndNullHandling">Inclusion and null handling</a>
+		 * </ul>
+		 *
+		 * @return This object.
+		 * @since 10.0.0
+		 */
+		public SELF nonDefault() {
+			return nonDefault(true);
+		}
+
+		/**
+		 * Same as {@link #nonDefault()} but allows you to explicitly specify the value.
+		 *
+		 * @param value The value for this setting.
+		 * @return This object.
+		 * @since 10.0.0
+		 */
+		public SELF nonDefault(boolean value) {
+			nonDefault = value;
+			return self();
+		}
+
 		/**
 		 * Serializer listener.
 		 *
@@ -934,6 +1021,7 @@ public class Serializer extends MarshallingTraverseContext {
 	protected final boolean addBeanTypes;
 	protected final boolean addRootType;
 	protected final boolean keepNullProperties;
+	protected final boolean nonDefault;
 	protected final boolean sortCollections;
 	protected final boolean sortMaps;
 	protected final boolean trimEmptyCollections;
@@ -962,6 +1050,7 @@ public class Serializer extends MarshallingTraverseContext {
 		addRootType = builder.addRootType;
 		keepNullProperties = builder.keepNullProperties;
 		listener = builder.listener;
+		nonDefault = builder.nonDefault;
 		produces = builder.produces;
 		sortCollections = builder.sortCollections;
 		sortMaps = builder.sortMaps;
@@ -1205,6 +1294,16 @@ public class Serializer extends MarshallingTraverseContext {
 	 */
 	protected final boolean isKeepNullProperties() { return keepNullProperties; }
 
+	/**
+	 * Don't serialize bean properties whose values equal the default for their type.
+	 *
+	 * @see Serializer.Builder#nonDefault()
+	 * @return
+	 * 	<jk>true</jk> if bean properties whose value equals the type / bean-constructed default are omitted.
+	 * @since 10.0.0
+	 */
+	protected final boolean isNonDefault() { return nonDefault; }
+
 	/**
 	 * Sort arrays and collections alphabetically.
 	 *
@@ -1257,6 +1356,7 @@ public class Serializer extends MarshallingTraverseContext {
 			.a(PROP_addRootType, addRootType)
 			.a(PROP_keepNullProperties, keepNullProperties)
 			.a(PROP_listener, listener)
+			.a(PROP_nonDefault, nonDefault)
 			.a(PROP_sortCollections, sortCollections)
 			.a(PROP_sortMaps, sortMaps)
 			.a(PROP_trimEmptyCollections, trimEmptyCollections)
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerConfig.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerConfig.java
index 62e1b7f9f4..57d3f88b57 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerConfig.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerConfig.java
@@ -360,6 +360,37 @@ public @interface SerializerConfig {
 	 */
 	String maxIndent() default "";
 
+	/**
+	 * Don't serialize properties whose value equals the type default.
+	 *
+	 * <p>
+	 * If <js>"true"</js>, bean properties whose current value equals the corresponding default — the Java type default
+	 * for primitives/boxed types, or the value returned by the bean's own no-arg constructor for bean properties — will
+	 * be omitted from the output.  This is Juneau's analog of Jackson's
+	 * <c>JsonInclude.Include.NON_DEFAULT</c>.
+	 *
+	 * <p>
+	 * Numeric values are compared by value (<c>1.0</c> equals <c>1</c>; <c>0.0</c> equals <c>-0.0</c>).
+	 *
+	 * <ul class='values'>
+	 * 	<li><js>"true"</js>
+	 * 	<li><js>"false"</js> (default)
+	 * </ul>
+	 *
+	 * <h5 class='section'>Notes:</h5><ul>
+	 * 	<li class='note'>
+	 * 		Supports <a class="doclink" href="https://juneau.apache.org/docs/topics/DefaultVarResolver">VarResolver.DEFAULT</a> (e.g. <js>"$C{myConfigVar}"</js>).
+	 * </ul>
+	 *
+	 * <h5 class='section'>See Also:</h5><ul>
+	 * 	<li class='jm'>{@link org.apache.juneau.marshall.serializer.Serializer.Builder#nonDefault()}
+	 * </ul>
+	 *
+	 * @return The annotation value.
+	 * @since 10.0.0
+	 */
+	String nonDefault() default "";
+
 	/**
 	 * Quote character.
 	 *
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerConfigAnnotation.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerConfigAnnotation.java
index 51cf56e403..612f7cb458 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerConfigAnnotation.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerConfigAnnotation.java
@@ -84,6 +84,7 @@ public class SerializerConfigAnnotation {
 			bool(a.addBeanTypes()).ifPresent(b::addBeanTypes);
 			bool(a.addRootType()).ifPresent(b::addRootType);
 			bool(a.keepNullProperties()).ifPresent(b::keepNullProperties);
+			bool(a.nonDefault()).ifPresent(b::nonDefault);
 			type(a.listener()).ifPresent(b::listener);
 			bool(a.sortCollections()).ifPresent(b::sortCollections);
 			bool(a.sortMaps()).ifPresent(b::sortMaps);
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerSession.java
index 221169a56a..592158229a 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/serializer/SerializerSession.java
@@ -24,6 +24,7 @@ import static org.apache.juneau.commons.utils.Utils.*;
 
 import java.io.*;
 import java.lang.reflect.*;
+import java.math.*;
 import java.net.*;
 import java.text.*;
 import java.time.*;
@@ -89,6 +90,7 @@ public class SerializerSession extends MarshallingTraverseSession {
 	private static final String PROP_uriContext = "uriContext";
 	private static final String PROP_uriResolver = "uriResolver";
 	private static final String PROP_keepNullProperties = "keepNullProperties";
+	private static final String PROP_nonDefault = "nonDefault";
 	private static final String PROP_trimStrings = "trimStrings";
 	private static final String PROP_addBeanTypes = "addBeanTypes";
 	private static final String PROP_addRootType = "addRootType";
@@ -101,6 +103,7 @@ public class SerializerSession extends MarshallingTraverseSession {
 	private static final String PROP_SerializerSession_schema = "SerializerSession.schema";
 	private static final String PROP_SerializerSession_uriContext = "SerializerSession.uriContext";
 	private static final String PROP_SerializerSession_keepNullProperties = "SerializerSession.keepNullProperties";
+	private static final String PROP_SerializerSession_nonDefault = "SerializerSession.nonDefault";
 	private static final String PROP_SerializerSession_trimStrings = "SerializerSession.trimStrings";
 	private static final String PROP_SerializerSession_addBeanTypes = "SerializerSession.addBeanTypes";
 	private static final String PROP_SerializerSession_addRootType = "SerializerSession.addRootType";
@@ -125,6 +128,7 @@ public class SerializerSession extends MarshallingTraverseSession {
 		private UriContext uriContext;
 		private VarResolverSession resolver;
 		private boolean keepNullProperties;
+		private boolean nonDefault;
 		private boolean trimStrings;
 		private boolean addBeanTypes;
 		private boolean addRootType;
@@ -145,6 +149,7 @@ public class SerializerSession extends MarshallingTraverseSession {
 			mediaTypeDefault(ctx.getResponseContentType());
 			uriContext = ctx.getUriContext();
 			keepNullProperties = ctx.isKeepNullProperties();
+			nonDefault = ctx.isNonDefault();
 			trimStrings = ctx.isTrimStrings();
 			addBeanTypes = ctx.isAddBeanTypes();
 			addRootType = ctx.isAddRootType();
@@ -190,6 +195,8 @@ public class SerializerSession extends MarshallingTraverseSession {
 					return uriContext(cvt(value, UriContext.class));
 				case PROP_keepNullProperties, PROP_SerializerSession_keepNullProperties:
 					return keepNullProperties(cvt(value, Boolean.class));
+				case PROP_nonDefault, PROP_SerializerSession_nonDefault:
+					return nonDefault(cvt(value, Boolean.class));
 				case PROP_trimStrings, PROP_SerializerSession_trimStrings:
 					return trimStrings(cvt(value, Boolean.class));
 				case PROP_addBeanTypes, PROP_SerializerSession_addBeanTypes:
@@ -289,6 +296,18 @@ public class SerializerSession extends MarshallingTraverseSession {
 			return self();
 		}
 
+		/**
+		 * Non-default inclusion.
+		 *
+		 * @param value The new value for this property.
+		 * @return This object.
+		 * @since 10.0.0
+		 */
+		public SELF nonDefault(boolean value) {
+			nonDefault = value;
+			return self();
+		}
+
 		/**
 		 * Trim strings.
 		 *
@@ -456,6 +475,7 @@ public class SerializerSession extends MarshallingTraverseSession {
 	private final UriResolver uriResolver;
 	private VarResolverSession vrs;
 	private final boolean keepNullProperties;
+	private final boolean nonDefault;
 	private final boolean trimStrings;
 	private final boolean addBeanTypes;
 	private final boolean addRootType;
@@ -479,6 +499,7 @@ public class SerializerSession extends MarshallingTraverseSession {
 		vrs = builder.resolver;
 		listener = BeanInstantiator.createOrNull(ctx.getListener());
 		keepNullProperties = builder.keepNullProperties;
+		nonDefault = builder.nonDefault;
 		trimStrings = builder.trimStrings;
 		addBeanTypes = builder.addBeanTypes;
 		addRootType = builder.addRootType;
@@ -544,6 +565,157 @@ public class SerializerSession extends MarshallingTraverseSession {
 		return false;
 	}
 
+	/**
+	 * Per-bean-property variant of {@link #canIgnoreValue(ClassMeta, String, Object)}.
+	 *
+	 * <p>
+	 * Applies the same standard inclusion rules ({@link Serializer.Builder#keepNullProperties() keepNullProperties},
+	 * {@link Serializer.Builder#trimEmptyCollections() trimEmptyCollections} /
+	 * {@link Serializer.Builder#trimEmptyMaps() trimEmptyMaps},
+	 * recursion detection), then additionally:
+	 * <ul>
+	 * 	<li>Treats {@link java.util.Optional#empty()} (and its primitive cousins
+	 * 		{@link java.util.OptionalInt}/{@link java.util.OptionalLong}/{@link java.util.OptionalDouble}) as a
+	 * 		<jk>null</jk> property value for inclusion purposes — the shared Optional/absent contract.
+	 * 	<li>Applies {@link Serializer.Builder#nonDefault() nonDefault} by comparing the surviving value against a
+	 * 		cached <i>reference instance</i> of the property's containing bean.  When no reference instance can be
+	 * 		built (no accessible no-arg ctor / instantiation throws), {@code nonDefault} is silently skipped for
+	 * 		that bean — the property is emitted normally rather than failing.
+	 * </ul>
+	 *
+	 * <p>
+	 * Precedence (first "omit" wins): (1) <jk>null</jk> / empty Optional, (2) trimEmpty*, (3) value-equals-default.
+	 *
+	 * @param pMeta The bean property meta for the value being considered.  Must not be <jk>null</jk>.
+	 * @param attrName The bean attribute name.  Must not be <jk>null</jk>.
+	 * @param value The current property value.
+	 * @return <jk>true</jk> if the property should be omitted from the output.
+	 * @throws SerializeException If recursion occurred.
+	 * @since 10.0.0
+	 */
+	public final boolean canIgnoreValue(BeanPropertyMeta pMeta, String attrName, Object value) throws SerializeException {
+		var cMeta = (ClassMeta<?>) pMeta.getBeanInfo();
+		// Resolve Optional<empty> / OptionalInt/Long/Double.isPresent()==false to null for the inclusion check.
+		var resolved = unwrapEmptyOptional(value);
+		if (canIgnoreValue(cMeta, attrName, resolved))
+			return true;
+		if (isNonDefault() && resolved != null)
+			return equalsBeanPropertyDefault(pMeta, attrName, resolved);
+		return false;
+	}
+
+	/**
+	 * Resolves an {@link Optional#empty()} (or the empty form of an {@link OptionalInt}/{@link OptionalLong}/
+	 * {@link OptionalDouble}) to <jk>null</jk>, leaving all other values unchanged.
+	 *
+	 * <p>
+	 * Nested {@code Optional<Optional<X>>} values are unwrapped recursively.
+	 *
+	 * @param value The value to resolve.
+	 * @return The resolved value (may be <jk>null</jk>).
+	 */
+	private static Object unwrapEmptyOptional(Object value) {
+		if (value == null)
+			return null;
+		if (value instanceof Optional<?> o)
+			return o.isEmpty() ? null : unwrapEmptyOptional(o.orElse(null));
+		if (value instanceof OptionalInt oi)
+			return oi.isEmpty() ? null : Integer.valueOf(oi.getAsInt());
+		if (value instanceof OptionalLong ol)
+			return ol.isEmpty() ? null : Long.valueOf(ol.getAsLong());
+		if (value instanceof OptionalDouble od)
+			return od.isEmpty() ? null : Double.valueOf(od.getAsDouble());
+		return value;
+	}
+
+	/**
+	 * Returns <jk>true</jk> when {@code value} equals the property's bean-constructed default per the
+	 * {@code nonDefault} contract.
+	 *
+	 * <p>
+	 * Resolves (and caches per-{@link ClassMeta}) the containing bean's reference instance.  Returns
+	 * <jk>false</jk> when no reference instance can be built (which signals "skip nonDefault for this bean" per
+	 * the documented contract).
+	 *
+	 * @param pMeta The bean-property meta.
+	 * @param attrName The bean attribute name.
+	 * @param value The surviving property value (non-null).
+	 * @return <jk>true</jk> if the value equals the property's default.
+	 */
+	private boolean equalsBeanPropertyDefault(BeanPropertyMeta pMeta, String attrName, Object value) {
+		var owning = pMeta.getBeanMeta();
+		if (owning == null)
+			return false;
+		var ownerClass = owning.getClassInfo();
+		if (ownerClass == null)
+			return false;
+		var ownerMeta = getClassMeta(ownerClass.inner());
+		var refValueHolder = ownerMeta.getProperty(NON_DEFAULT_PROPERTY_CACHE_KEY, cm -> {
+			var instance = BeanInstantiator.createOrNull(cm.inner());
+			return instance == null ? NO_REFERENCE_INSTANCE : instance;
+		});
+		var ref = refValueHolder.orElse(null);
+		if (ref == null || ref == NO_REFERENCE_INSTANCE)
+			return false;
+		// Resolve the default value via the bean's accessor surface so it composes with @MarshalledProp
+		// transforms.  Wrap any throw in a guard — a bean whose getter throws on a fresh instance is just
+		// treated as "no default available", which is safer than aborting the serialize.
+		Object defaultValue;
+		try {
+			defaultValue = pMeta.get(toBeanMap(ref), attrName);
+		} catch (RuntimeException e) {  // HTT — defensive: getter on reference instance throws.
+			return false;
+		}
+		var resolvedDefault = unwrapEmptyOptional(defaultValue);
+		return defaultEquals(value, resolvedDefault);
+	}
+
+	/**
+	 * {@code Objects.equals} with a numeric-value shortcut so {@code 1.0} equals {@code 1},
+	 * {@code BigDecimal("1.0")} equals {@code BigDecimal("1")}, and {@code 0.0}/{@code -0.0} equal each other.
+	 *
+	 * @param a First value.
+	 * @param b Second value.
+	 * @return <jk>true</jk> if the values are equal under the {@code nonDefault} contract.
+	 */
+	@SuppressWarnings({
+		"java:S1244" // Float equality is intentional: caller has already narrowed to numeric short-circuit; BigDecimal compareTo handles the value-equality semantics.
+	})
+	private static boolean defaultEquals(Object a, Object b) {
+		if (a == b)
+			return true;
+		if (a == null || b == null)
+			return false;
+		if (a instanceof Number an && b instanceof Number bn) {
+			// Treat NaN as equal to NaN (Objects.equals semantics) so two NaN-typed defaults match.
+			var anan = (an instanceof Double d && Double.isNaN(d)) || (an instanceof Float f && Float.isNaN(f));
+			var bnan = (bn instanceof Double d && Double.isNaN(d)) || (bn instanceof Float f && Float.isNaN(f));
+			if (anan || bnan)
+				return anan && bnan;
+			try {
+				return toBigDecimal(an).compareTo(toBigDecimal(bn)) == 0;
+			} catch (NumberFormatException e) {  // HTT — defensive: a Number subclass whose toString() isn't a valid decimal.
+				return Objects.equals(a, b);
+			}
+		}
+		return Objects.equals(a, b);
+	}
+
+	private static BigDecimal toBigDecimal(Number n) {
+		if (n instanceof BigDecimal bd)
+			return bd;
+		if (n instanceof BigInteger bi)
+			return new BigDecimal(bi);
+		if (n instanceof Float || n instanceof Double)
+			return BigDecimal.valueOf(n.doubleValue());
+		return BigDecimal.valueOf(n.longValue());
+	}
+
+	/** Cache key under which the per-{@link ClassMeta} reference instance is memoized for {@code nonDefault}. */
+	private static final String NON_DEFAULT_PROPERTY_CACHE_KEY = "SerializerSession.nonDefault.referenceInstance";
+	/** Sentinel stored when a reference instance could not be built (cached so we don't retry on every call). */
+	private static final Object NO_REFERENCE_INSTANCE = new Object();
+
 	/**
 	 * Consumes each entry in the list.
 	 *
@@ -1281,6 +1453,16 @@ public class SerializerSession extends MarshallingTraverseSession {
 	 */
 	protected final boolean isKeepNullProperties() { return keepNullProperties; }
 
+	/**
+	 * Don't serialize bean properties whose values equal the default for their type.
+	 *
+	 * @see Serializer.Builder#nonDefault()
+	 * @return
+	 * 	<jk>true</jk> if bean properties whose value equals the type / bean-constructed default are omitted.
+	 * @since 10.0.0
+	 */
+	protected final boolean isNonDefault() { return nonDefault; }
+
 	/**
 	 * Sort arrays and collections alphabetically.
 	 *
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/toml/TomlSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/toml/TomlSerializerSession.java
index 2c395bce5b..73e7071c49 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/toml/TomlSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/toml/TomlSerializerSession.java
@@ -170,7 +170,7 @@ public class TomlSerializerSession extends WriterSerializerSession implements Re
 			if (nn(thrown))
 				onBeanGetterException(pMeta, thrown);
 			ClassMeta<?> cMeta = (ClassMeta<?>) pMeta.getBeanInfo();
-			if (canIgnoreValue(cMeta, key, value))
+			if (canIgnoreValue(pMeta, key, value))
 				return;
 			ClassMeta<?> aType = value == null ? cMeta : getClassMetaForObject(value, cMeta);
 			if (isSimpleOrInlineTable(aType, value, pMeta)) {
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/uon/UonSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/uon/UonSerializerSession.java
index 2618f25175..7d3f2dd518 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/uon/UonSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/uon/UonSerializerSession.java
@@ -226,7 +226,7 @@ public class UonSerializerSession extends WriterSerializerSession implements Htt
 			if (nn(thrown))
 				onBeanGetterException(pMeta, thrown);
 
-			if (canIgnoreValue(cMeta, key, value))
+			if (canIgnoreValue(pMeta, key, value))
 				return;
 
 			addComma.ifSet(() -> out.append(',')).set();
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/urlencoding/UrlEncodingSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/urlencoding/UrlEncodingSerializerSession.java
index e3ec43ce63..b8c8614d2a 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/urlencoding/UrlEncodingSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/urlencoding/UrlEncodingSerializerSession.java
@@ -230,7 +230,7 @@ public class UrlEncodingSerializerSession extends UonSerializerSession {
 			if (nn(thrown))
 				onBeanGetterException(pMeta, thrown);
 
-			if (canIgnoreValue(sMeta, key, value))
+			if (canIgnoreValue(pMeta, key, value))
 				return;
 
 			if (nn(value) && shouldUseExpandedParams(pMeta)) {
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlSerializerSession.java
index 85232fff40..e19efc0ef2 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlSerializerSession.java
@@ -375,12 +375,12 @@ public class XmlSerializerSession extends WriterSerializerSession implements Rec
 		return xcm.getFormat() == XMLTEXT;
 	}
 
-	private Optional<Map.Entry<String,Object>> getPropertyKeyValueIfNotIgnored(BeanPropertyValue p, BeanPropertyMeta pMeta, ClassMeta<?> cMeta) {
+	private Optional<Map.Entry<String,Object>> getPropertyKeyValueIfNotIgnored(BeanPropertyValue p, BeanPropertyMeta pMeta) {
 		var key = p.getName();
 		var value = p.getValue();
 		if (nn(p.getThrown()))
 			onBeanGetterException(pMeta, p.getThrown());
-		if (canIgnoreValue(cMeta, key, value))
+		if (canIgnoreValue(pMeta, key, value))
 			return opte();
 		return opt(new SimpleEntry<>(key, value));
 	}
@@ -418,7 +418,7 @@ public class XmlSerializerSession extends WriterSerializerSession implements Rec
 			if (attrs.contains(n) || attrs.contains("*") || n.equals(attrsProperty)) {
 				var pMeta = p.getMeta();
 				if (pMeta.canRead()) {
-					var kv = getPropertyKeyValueIfNotIgnored(p, pMeta, (ClassMeta<?>) p.getBeanInfo());
+					var kv = getPropertyKeyValueIfNotIgnored(p, pMeta);
 					if (kv.isEmpty())
 						continue;
 					var key = kv.get().getKey();
@@ -469,7 +469,7 @@ public class XmlSerializerSession extends WriterSerializerSession implements Rec
 					if ((contentType.isCollection() && ((Collection)content).isEmpty()) || (contentType.isArray() && Array.getLength(content) == 0))
 						hasContent = false;
 				} else if (elements.contains(n) || collapsedElements.contains(n) || elements.contains("*") || collapsedElements.contains("*")) {
-					var kv = getPropertyKeyValueIfNotIgnored(p, pMeta, cMeta);
+					var kv = getPropertyKeyValueIfNotIgnored(p, pMeta);
 					if (kv.isEmpty())
 						continue;
 					var key = kv.get().getKey();
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/yaml/YamlSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/yaml/YamlSerializerSession.java
index 4336c678e8..c6021b1d49 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/yaml/YamlSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/yaml/YamlSerializerSession.java
@@ -277,7 +277,7 @@ public class YamlSerializerSession extends WriterSerializerSession implements Re
 			if (nn(thrown))
 				onBeanGetterException(pMeta, thrown);
 
-			if (canIgnoreValue(cMeta, key, value))
+			if (canIgnoreValue(pMeta, key, value))
 				return;
 
 			addAtt.ifSet(() -> out.yamlIndent(i)).set();
diff --git a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/NullInclusionAndCoercion_Test.java b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/NullInclusionAndCoercion_Test.java
new file mode 100644
index 0000000000..c7620c34e3
--- /dev/null
+++ b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/NullInclusionAndCoercion_Test.java
@@ -0,0 +1,569 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.marshall;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.math.*;
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.hjson.*;
+import org.apache.juneau.marshall.hocon.*;
+import org.apache.juneau.marshall.ini.*;
+import org.apache.juneau.marshall.jcs.*;
+import org.apache.juneau.marshall.json.*;
+import org.apache.juneau.marshall.proto.*;
+import org.apache.juneau.marshall.serializer.*;
+import org.apache.juneau.marshall.toml.*;
+import org.apache.juneau.marshall.uon.*;
+import org.apache.juneau.marshall.xml.*;
+import org.apache.juneau.marshall.yaml.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Coverage for the 10.0.0 null-inclusion / null-coercion knobs and the first-class {@link BitSetFormat} /
+ * primitive-{@link Optional} handling.
+ *
+ * <p>
+ * Exercises {@code nonDefault} on the serializer side, the {@link Nulls} per-property / context-level policy on the
+ * parser side, the shared {@link Optional} contract both sides honor, the {@code MarshalledPropertyPostProcessor}
+ * null-coercion transforms, the {@link BitSetFormat} format/parse paths, and the primitive-{@link Optional}
+ * converter / {@link ClassMeta} plumbing.
+ *
+ * <p>
+ * All formats touched here ({@code json}, plus the {@code hjson/hocon/ini/jcs/proto/toml/uon/xml/yaml} families used
+ * by the per-format inclusion-branch test) live in this module, so the full matrix runs as a unit test with no
+ * dependency on the integration-tests surface.
+ */
+class NullInclusionAndCoercion_Test extends TestBase {
+
+	//====================================================================================================
+	// a. Serializer nonDefault — inclusion knob + numeric-default equality (defaultEquals / toBigDecimal)
+	//====================================================================================================
+
+	public static class A1 {
+		public int i;
+		public Integer boxed;
+		public String s;
+		public double d;
+	}
+
+	@Test void a01_nonDefault_omitsPrimitiveDefaults() throws Exception {
+		assertEquals("{}", JsonSerializer.create().nonDefault().build().serializeToString(new A1()));
+	}
+
+	@Test void a02_nonDefault_emitsNonDefaultPrimitives() throws Exception {
+		var b = new A1();
+		b.i = 5;
+		assertEquals("{\"i\":5}", JsonSerializer.create().nonDefault().build().serializeToString(b));
+	}
+
+	@Test void a03_nonDefault_disabledByDefault() throws Exception {
+		assertEquals("{\"d\":0.0,\"i\":0}", JsonSerializer.create().build().serializeToString(new A1()));
+	}
+
+	@Test void a04_nonDefault_keepNullPropertiesFirst() throws Exception {
+		assertEquals("{\"boxed\":null,\"s\":null}", JsonSerializer.create().nonDefault().keepNullProperties().build().serializeToString(new A1()));
+	}
+
+	public static class A2 {
+		public String name = "hello";
+		public int count = 7;
+	}
+
+	@Test void a05_nonDefault_usesBeanConstructedDefaults_cached() throws Exception {
+		var s = JsonSerializer.create().nonDefault().build();
+		// Serialize twice so the per-ClassMeta reference instance is resolved once then read from cache.
+		assertEquals("{}", s.serializeToString(new A2()));
+		var b = new A2();
+		b.name = "world";
+		assertEquals("{\"name\":\"world\"}", s.serializeToString(b));
+	}
+
+	// Numeric-default equality across Number subtypes — exercises defaultEquals() + toBigDecimal() branches.
+	public static class A3 {
+		public long l = 5L;
+		public float f = 1.5f;
+		public BigInteger bi = BigInteger.TEN;
+		public BigDecimal bd = new BigDecimal("1.0");
+		public double nan = Double.NaN;
+		public float fnan = Float.NaN;
+	}
+
+	@Test void a06_nonDefault_numericEquality_allMatchDefaults() throws Exception {
+		// Every field equals its bean-constructed default (Double + Float NaN both match) → all omitted.
+		assertEquals("{}", JsonSerializer.create().nonDefault().build().serializeToString(new A3()));
+	}
+
+	// Value-equal-but-not-reference-equal + String + null-default → defaultEquals non-Number/null/BigInteger paths.
+	public static class A6 {
+		public String name = "x";
+		public String nul;                            // reference default is null
+		public BigInteger big = new BigInteger("10"); // distinct instance from the value below
+	}
+
+	@Test void a09_nonDefault_defaultEquals_nonNumericAndNullAndBigInteger() throws Exception {
+		var b = new A6();
+		b.name = "y";                       // diverges from "x" (both non-null, non-Number) → emitted
+		b.nul = "z";                        // reference default null, value non-null → emitted
+		b.big = new BigInteger("10");       // value-equal but != reference → BigDecimal compare → omitted
+		var json = JsonSerializer.create().nonDefault().build().serializeToString(b);
+		assertTrue(json.contains("\"name\":\"y\""), json);
+		assertTrue(json.contains("\"nul\":\"z\""), json);
+		assertFalse(json.contains("\"big\""), json);
+	}
+
+	@Test void a07_nonDefault_numericEquality_divergent() throws Exception {
+		var b = new A3();
+		b.l = 6L;
+		b.bd = new BigDecimal("2.0");
+		b.nan = 1.0;  // NaN default vs non-NaN value → emitted
+		var json = JsonSerializer.create().nonDefault().build().serializeToString(b);
+		assertTrue(json.contains("\"l\":6"), json);
+		assertTrue(json.contains("\"bd\":2.0"), json);
+		assertTrue(json.contains("\"nan\":1.0"), json);
+		assertFalse(json.contains("\"f\":"), json);
+		assertFalse(json.contains("\"bi\":"), json);
+	}
+
+	// Bean with no accessible no-arg constructor — nonDefault cannot build a reference instance, so the
+	// "no reference instance" fallback fires and every property is emitted.
+	public static class A4 {
+		public int x;
+		public A4(int x) { this.x = x; }
+	}
+
+	@Test void a08_nonDefault_noNoArgCtor_emitsEverything() throws Exception {
+		assertEquals("{\"x\":5}", JsonSerializer.create().nonDefault().build().serializeToString(new A4(5)));
+	}
+
+	//====================================================================================================
+	// b. Shared Optional contract on serialize (Optional<T> + OptionalInt/Long/Double)
+	//====================================================================================================
+
+	public static class B1 {
+		public Optional<String> a = Optional.empty();
+		public Optional<String> b = Optional.of("x");
+		public OptionalInt c = OptionalInt.empty();
+		public OptionalInt d = OptionalInt.of(7);
+		public OptionalLong e = OptionalLong.of(9L);
+		public OptionalDouble f = OptionalDouble.of(2.5);
+	}
+
+	@Test void b01_emptyOptionals_omittedByDefault() throws Exception {
+		var json = JsonSerializer.DEFAULT.serializeToString(new B1());
+		assertFalse(json.contains("\"a\":"), json);
+		assertFalse(json.contains("\"c\":"), json);
+	}
+
+	@Test void b02_presentOptionals_emittedUnwrapped() throws Exception {
+		var json = JsonSerializer.DEFAULT.serializeToString(new B1());
+		assertTrue(json.contains("\"b\":\"x\""), json);
+		assertTrue(json.contains("\"d\":7"), json);
+		assertTrue(json.contains("\"e\":9"), json);
+		assertTrue(json.contains("\"f\":2.5"), json);
+	}
+
+	@Test void b03_emptyOptionals_emittedAsNullWhenKept() throws Exception {
+		var json = JsonSerializer.create().keepNullProperties().build().serializeToString(new B1());
+		assertTrue(json.contains("\"a\":null"), json);
+		assertTrue(json.contains("\"c\":null"), json);
+	}
+
+	public static class B2 {
+		public OptionalLong e = OptionalLong.empty();
+		public OptionalDouble f = OptionalDouble.empty();
+	}
+
+	@Test void b04_emptyPrimitiveLongDouble_unwrapToNull() throws Exception {
+		// keepNullProperties forces traversal of the empty OptionalLong/OptionalDouble — exercises both the
+		// canIgnoreValue unwrap and the traverse-session getOptionalValue empty branches.
+		var json = JsonSerializer.create().keepNullProperties().build().serializeToString(new B2());
+		assertTrue(json.contains("\"e\":null"), json);
+		assertTrue(json.contains("\"f\":null"), json);
+	}
+
+	//====================================================================================================
+	// c. Shared Optional contract on parse + top-level primitive-optional coercion
+	//====================================================================================================
+
+	@Test void c01_absentAndNullOptional_resolveToEmpty() throws Exception {
+		var b = JsonParser.DEFAULT.parse("{\"a\":null,\"c\":null,\"e\":null,\"f\":null}", B1.class);
+		assertTrue(b.a.isEmpty());
+		assertTrue(b.c.isEmpty());
+		assertTrue(b.e.isEmpty());
+		assertTrue(b.f.isEmpty());
+	}
+
+	@Test void c02_presentPrimitiveOptionals_parse() throws Exception {
+		var b = JsonParser.DEFAULT.parse("{\"d\":42,\"e\":43,\"f\":4.5}", B1.class);
+		assertEquals(42, b.d.getAsInt());
+		assertEquals(43L, b.e.getAsLong());
+		assertEquals(4.5, b.f.getAsDouble());
+	}
+
+	@Test void c03_topLevelPrimitiveOptional_parse() throws Exception {
+		assertEquals(5, JsonParser.DEFAULT.parse("5", OptionalInt.class).getAsInt());
+		assertEquals(6L, JsonParser.DEFAULT.parse("6", OptionalLong.class).getAsLong());
+		assertEquals(7.5, JsonParser.DEFAULT.parse("7.5", OptionalDouble.class).getAsDouble());
+		assertTrue(JsonParser.DEFAULT.parse("null", OptionalInt.class).isEmpty());
+	}
+
+	//====================================================================================================
+	// cb. BitSet — first-class handling via BitSetFormat (round-trip, no swap registered)
+	//====================================================================================================
+
+	public static class CB1 {
+		public BitSet bits;
+	}
+
+	private static BitSet bitSet(int... indices) {
+		var b = new BitSet();
+		for (var i : indices)
+			b.set(i);
+		return b;
+	}
+
+	@Test void cb01_indices_default_roundTrip() throws Exception {
+		var bean = new CB1();
+		bean.bits = bitSet(0, 2, 5);
+		var json = JsonSerializer.DEFAULT.serializeToString(bean);
+		assertTrue(json.contains("\"bits\":\"0,2,5\""), json);
+		assertEquals(bean.bits, JsonParser.DEFAULT.parse(json, CB1.class).bits);
+	}
+
+	@Test void cb02_bits_format_roundTrip() throws Exception {
+		var s = JsonSerializer.create().bitSetFormat(BitSetFormat.BITS).build();
+		var p = JsonParser.create().bitSetFormat(BitSetFormat.BITS).build();
+		var bean = new CB1();
+		bean.bits = bitSet(0, 2, 5);
+		var json = s.serializeToString(bean);
+		assertTrue(json.contains("\"bits\":\"101001\""), json);
+		assertEquals(bean.bits, p.parse(json, CB1.class).bits);
+	}
+
+	@Test void cb03_hex_format_roundTrip() throws Exception {
+		var s = JsonSerializer.create().bitSetFormat(BitSetFormat.HEX).build();
+		var p = JsonParser.create().bitSetFormat(BitSetFormat.HEX).build();
+		var bean = new CB1();
+		bean.bits = bitSet(0, 2, 5);
+		var json = s.serializeToString(bean);
+		assertTrue(json.contains("\"bits\":\"25\""), json);
+		assertEquals(bean.bits, p.parse(json, CB1.class).bits);
+	}
+
+	public static class CB2 {
+		@MarshalledProp(bitSetFormat = BitSetFormat.BITS)
+		public BitSet bits;
+	}
+
+	@Test void cb04_perProperty_overridesContext() throws Exception {
+		var bean = new CB2();
+		bean.bits = bitSet(0, 2, 5);
+		var json = JsonSerializer.DEFAULT.serializeToString(bean);
+		assertTrue(json.contains("\"bits\":\"101001\""), json);
+		assertEquals(bean.bits, JsonParser.DEFAULT.parse(json, CB2.class).bits);
+	}
+
+	@Marshalled(bitSetFormat = BitSetFormat.HEX)
+	public static class CB3 {
+		public BitSet bits;
+	}
+
+	@Test void cb05_perClass_overridesContext() throws Exception {
+		var bean = new CB3();
+		bean.bits = bitSet(0, 2, 5);
+		var json = JsonSerializer.DEFAULT.serializeToString(bean);
+		assertTrue(json.contains("\"bits\":\"25\""), json);
+		assertEquals(bean.bits, JsonParser.DEFAULT.parse(json, CB3.class).bits);
+	}
+
+	@Test void cb06_emptyBitSet_roundTrip() throws Exception {
+		var bean = new CB1();
+		bean.bits = new BitSet();
+		var json = JsonSerializer.DEFAULT.serializeToString(bean);
+		assertEquals(new BitSet(), JsonParser.DEFAULT.parse(json, CB1.class).bits);
+	}
+
+	//====================================================================================================
+	// cc. BitSetFormat — direct unit coverage of format()/parse()/isNumeric()
+	//====================================================================================================
+
+	@Test void cc01_format_allConstants() {
+		var bs = bitSet(0, 2, 5);
+		assertEquals("0,2,5", BitSetFormat.format(bs, BitSetFormat.INDICES));
+		assertEquals("0,2,5", BitSetFormat.format(bs, BitSetFormat.NOT_SET));   // NOT_SET → INDICES
+		assertEquals("0,2,5", BitSetFormat.format(bs, null));                   // null → INDICES
+		assertEquals("101001", BitSetFormat.format(bs, BitSetFormat.BITS));
+		assertEquals("25", BitSetFormat.format(bs, BitSetFormat.HEX));
+		assertEquals("", BitSetFormat.format(new BitSet(), BitSetFormat.INDICES));
+		assertEquals("", BitSetFormat.format(new BitSet(), BitSetFormat.BITS));
+		assertEquals("", BitSetFormat.format(new BitSet(), BitSetFormat.HEX));
+		assertNull(BitSetFormat.format(null, BitSetFormat.INDICES));            // null value → null
+	}
+
+	@Test void cc02_parse_allConstants() {
+		var expected = bitSet(0, 2, 5);
+		assertEquals(expected, BitSetFormat.parse("0,2,5", BitSetFormat.INDICES));
+		assertEquals(expected, BitSetFormat.parse(" 0 , 2 , 5 ", BitSetFormat.NOT_SET));  // NOT_SET → INDICES, trims
+		assertEquals(expected, BitSetFormat.parse("0,2,5", null));                        // null → INDICES
+		assertEquals(expected, BitSetFormat.parse("101001", BitSetFormat.BITS));
+		assertEquals(expected, BitSetFormat.parse("25", BitSetFormat.HEX));
+		assertEquals(new BitSet(), BitSetFormat.parse("", BitSetFormat.INDICES));         // empty → empty BitSet
+		assertEquals(new BitSet(), BitSetFormat.parse("   ", BitSetFormat.HEX));          // blank → empty BitSet
+		assertNull(BitSetFormat.parse(null, BitSetFormat.INDICES));                       // null → null
+	}
+
+	@Test void cc03_parse_invalidIndices_throws() {
+		assertThrows(IllegalArgumentException.class, () -> BitSetFormat.parse("0,x,5", BitSetFormat.INDICES));
+	}
+
+	@Test void cc04_parse_invalidHex_throws() {
+		assertThrows(IllegalArgumentException.class, () -> BitSetFormat.parse("2", BitSetFormat.HEX));   // odd length
+		assertThrows(IllegalArgumentException.class, () -> BitSetFormat.parse("zz", BitSetFormat.HEX));  // bad nibble
+	}
+
+	@Test void cc05_isNumeric_alwaysFalse() {
+		for (var f : BitSetFormat.values())
+			assertFalse(f.isNumeric());
+	}
+
+	//====================================================================================================
+	// d. Parser context-level nulls modes (LEAVE / EMPTY / DEFAULT / SKIP)
+	//====================================================================================================
+
+	public static class D1 {
+		public String s;
+		public Integer i;
+		public List<String> list;
+	}
+
+	@Test void d01_nulls_LEAVE_default() throws Exception {
+		var b = JsonParser.DEFAULT.parse("{\"s\":null,\"i\":null,\"list\":null}", D1.class);
+		assertNull(b.s);
+		assertNull(b.i);
+		assertNull(b.list);
+	}
+
+	@Test void d02_nulls_EMPTY() throws Exception {
+		var b = JsonParser.create().nulls(Nulls.EMPTY).build().parse("{\"s\":null,\"i\":null,\"list\":null}", D1.class);
+		assertEquals("", b.s);
+		assertNull(b.i);            // boxed Integer has no empty sentinel → LEAVE fallback
+		assertNotNull(b.list);
+		assertTrue(b.list.isEmpty());
+	}
+
+	public static class D2 {
+		public String s = "hello";
+		public int i = 5;
+	}
+
+	@Test void d03_nulls_DEFAULT() throws Exception {
+		var b = JsonParser.create().nulls(Nulls.DEFAULT).build().parse("{\"s\":null,\"i\":null}", D2.class);
+		assertEquals("hello", b.s);
+		assertEquals(5, b.i);
+	}
+
+	@Test void d04_nulls_SKIP() throws Exception {
+		var b = JsonParser.create().nulls(Nulls.SKIP).build().parse("{\"s\":null,\"i\":null}", D2.class);
+		assertEquals("hello", b.s);
+		assertEquals(5, b.i);
+	}
+
+	@Test void d05_nulls_nullArg_treatedAsNotSet() throws Exception {
+		// nulls(null) → NOT_SET → behaves as LEAVE.
+		var b = JsonParser.create().nulls(null).build().parse("{\"s\":null}", D1.class);
+		assertNull(b.s);
+	}
+
+	//====================================================================================================
+	// e. EMPTY-mode emptyValueFor — exhaustive per-type coverage
+	//====================================================================================================
+
+	public static class E1 {
+		public String s;
+		public CharSequence cs;
+		public int[] arr;
+		public List<String> list;
+		public Collection<String> coll;
+		public Set<String> set;
+		public Map<String,String> map;
+		public Optional<String> opt;
+		public OptionalInt oi;
+		public OptionalLong ol;
+		public OptionalDouble od;
+		public int pi;
+		public long pl;
+		public double pd;
+		public float pf;
+		public short psh;
+		public byte pby;
+		public boolean pbo;
+		public char pch;
+		public Object obj;          // Object/unknown → no empty sentinel → null (LEAVE fallback)
+		public StringBuilder sb;    // reference type with no canonical empty → null (LEAVE fallback)
+	}
+
+	@Test void e01_empty_substitutesPerType() throws Exception {
+		var json = "{\"s\":null,\"cs\":null,\"arr\":null,\"list\":null,\"coll\":null,\"set\":null,\"map\":null,"
+			+ "\"opt\":null,\"oi\":null,\"ol\":null,\"od\":null,\"pi\":null,\"pl\":null,\"pd\":null,\"pf\":null,"
+			+ "\"psh\":null,\"pby\":null,\"pbo\":null,\"pch\":null,\"obj\":null,\"sb\":null}";
+		var b = JsonParser.create().nulls(Nulls.EMPTY).build().parse(json, E1.class);
+		assertEquals("", b.s);
+		assertEquals("", b.cs);
+		assertNull(b.obj);
+		assertNull(b.sb);
+		assertNotNull(b.arr);
+		assertEquals(0, b.arr.length);
+		assertTrue(b.list.isEmpty());
+		assertTrue(b.coll.isEmpty());
+		assertTrue(b.set.isEmpty());
+		assertTrue(b.map.isEmpty());
+		assertTrue(b.opt.isEmpty());
+		assertTrue(b.oi.isEmpty());
+		assertTrue(b.ol.isEmpty());
+		assertTrue(b.od.isEmpty());
+		assertEquals(0, b.pi);
+		assertEquals(0L, b.pl);
+		assertEquals(0d, b.pd);
+		assertEquals(0f, b.pf);
+		assertEquals((short) 0, b.psh);
+		assertEquals((byte) 0, b.pby);
+		assertFalse(b.pbo);
+		assertEquals((char) 0, b.pch);
+	}
+
+	//====================================================================================================
+	// f. @MarshalledProp(nulls=...) per-property override + Optional contract under coercion
+	//====================================================================================================
+
+	public static class F1 {
+		@MarshalledProp(nulls = Nulls.EMPTY)
+		public String s;
+		@MarshalledProp(nulls = Nulls.SKIP)
+		public String t = "initial";
+		public String u;  // inherits context default
+	}
+
+	@Test void f01_perProperty_overridesContext() throws Exception {
+		var b = JsonParser.DEFAULT.parse("{\"s\":null,\"t\":null,\"u\":null}", F1.class);
+		assertEquals("", b.s);
+		assertEquals("initial", b.t);
+		assertNull(b.u);
+	}
+
+	public static class F2 {
+		public Optional<String> a;
+		@MarshalledProp(nulls = Nulls.EMPTY)
+		public Optional<String> b;
+		@MarshalledProp(nulls = Nulls.DEFAULT)
+		public Optional<String> c = Optional.of("seed");
+		public OptionalInt d;
+		public OptionalLong e;
+		public OptionalDouble f;
+	}
+
+	@Test void f02_optionalContract_underCoercion() throws Exception {
+		var b = JsonParser.create().nulls(Nulls.EMPTY).build().parse("{\"a\":null,\"b\":null,\"d\":null,\"e\":null,\"f\":null}", F2.class);
+		assertTrue(b.a.isEmpty());
+		assertTrue(b.b.isEmpty());
+		assertTrue(b.d.isEmpty());
+		assertTrue(b.e.isEmpty());
+		assertTrue(b.f.isEmpty());
+	}
+
+	@Test void f03_optional_DEFAULT_usesBeanDefault() throws Exception {
+		var b = JsonParser.DEFAULT.parse("{\"c\":null}", F2.class);
+		assertTrue(b.c.isPresent());
+		assertEquals("seed", b.c.orElse(null));
+	}
+
+	// @MarshalledProp(nulls=...) on the getter and setter surfaces (not just the field).
+	public static class F4 {
+		private String g;
+		private String s;
+		@MarshalledProp(nulls = Nulls.EMPTY)
+		public String getG() { return g; }
+		public void setG(String v) { g = v; }
+		public String getS() { return s; }
+		@MarshalledProp(nulls = Nulls.EMPTY)
+		public void setS(String v) { s = v; }
+	}
+
+	@Test void f04_perProperty_nullsOnGetterAndSetter() throws Exception {
+		var b = JsonParser.DEFAULT.parse("{\"g\":null,\"s\":null}", F4.class);
+		assertEquals("", b.g);
+		assertEquals("", b.s);
+	}
+
+	//====================================================================================================
+	// g. Per-format inclusion-branch coverage (canIgnoreValue true-path on every serializer family)
+	//====================================================================================================
+
+	public static class G1 {
+		public Optional<String> a = Optional.empty();  // non-null but ignorable → reaches canIgnoreValue
+		public String b = "keep";
+	}
+
+	@Test void g01_perFormat_ignoresEmptyOptional() {
+		// An empty Optional is a non-null value that survives the null pre-filter and reaches canIgnoreValue,
+		// whose ignore-branch then fires on every serializer family.  serialize(Object) traverses the bean
+		// regardless of char/stream output shape.
+		var bean = new G1();
+		List<Serializer> serializers = List.of(
+			HjsonSerializer.DEFAULT, HoconSerializer.DEFAULT, IniSerializer.DEFAULT, JcsSerializer.DEFAULT,
+			ProtoSerializer.DEFAULT, TomlSerializer.DEFAULT, UonSerializer.DEFAULT, XmlSerializer.DEFAULT,
+			YamlSerializer.DEFAULT);
+		for (var s : serializers)
+			assertDoesNotThrow(() -> s.serialize(bean), () -> s.getClass().getSimpleName());
+	}
+
+	//====================================================================================================
+	// h. Primitive-optional conversions (BasicConverter / CachingConverter)
+	//====================================================================================================
+
+	@Test void h01_convert_nullAndNumber() throws Exception {
+		var bc = MarshallingContext.DEFAULT;
+		assertEquals(OptionalInt.empty(), bc.convertToType(null, OptionalInt.class));
+		assertEquals(OptionalLong.empty(), bc.convertToType(null, OptionalLong.class));
+		assertEquals(OptionalDouble.empty(), bc.convertToType(null, OptionalDouble.class));
+		assertEquals(OptionalInt.of(5), bc.convertToType(5, OptionalInt.class));
+		assertEquals(OptionalLong.of(6L), bc.convertToType("6", OptionalLong.class));
+		assertEquals(OptionalDouble.of(7.5), bc.convertToType(7.5, OptionalDouble.class));
+	}
+
+	@Test void h02_convert_fromOptionalInputs() throws Exception {
+		var bc = MarshallingContext.DEFAULT;
+		assertEquals(OptionalInt.of(9), bc.convertToType(Optional.of(9), OptionalInt.class));
+		assertEquals(OptionalLong.empty(), bc.convertToType(Optional.empty(), OptionalLong.class));
+		assertEquals(OptionalLong.of(4L), bc.convertToType(OptionalInt.of(4), OptionalLong.class));
+		assertEquals(OptionalInt.of(3), bc.convertToType(OptionalLong.of(3L), OptionalInt.class));
+		assertEquals(OptionalInt.of(2), bc.convertToType(OptionalDouble.of(2.0), OptionalInt.class));
+		// Cross-type empties (different out-type avoids the identity short-circuit) → exercise each empty-unwrap
+		// branch plus the OptionalDouble empty sentinel.
+		assertEquals(OptionalLong.empty(), bc.convertToType(OptionalInt.empty(), OptionalLong.class));
+		assertEquals(OptionalInt.empty(), bc.convertToType(OptionalLong.empty(), OptionalInt.class));
+		assertEquals(OptionalInt.empty(), bc.convertToType(OptionalDouble.empty(), OptionalInt.class));
+		assertEquals(OptionalDouble.empty(), bc.convertToType(Optional.empty(), OptionalDouble.class));
+	}
+
+	@Test void h03_convert_unwrappedToNull_yieldsEmpty() throws Exception {
+		// Optional containing a blank string → unwraps then coerces to a null number → empty primitive optional.
+		assertEquals(OptionalInt.empty(), MarshallingContext.DEFAULT.convertToType(Optional.of(""), OptionalInt.class));
+	}
+}
diff --git a/scripts/coverage.py b/scripts/coverage.py
index 577bd29518..f6a10563c1 100755
--- a/scripts/coverage.py
+++ b/scripts/coverage.py
@@ -16,6 +16,15 @@ Coverage reporter for Apache Juneau.
 
 Shows JaCoCo branch and instruction coverage for a source file or folder.
 
+Coverage data is aggregated across ALL modules' unit tests (plus the
+integration-test suite), not just juneau-integration-tests.  JaCoCo writes a
+separate target/jacoco.exec per Maven module, so a class exercised only by
+unit tests in its own module (e.g. juneau-core/juneau-marshall) used to read
+0% because only the integration exec was consulted.  This script discovers
+every module's target/jacoco.exec and feeds them all -- together with each
+owning module's target/classes -- to the JaCoCo CLI to produce one combined,
+repo-wide report.
+
 Usage:
     ./scripts/coverage.py <path> [options]
 
@@ -24,27 +33,48 @@ Arguments:
             Paths can be absolute or relative to the repo root.
 
 Options:
-    --run, -r       Re-run tests before reporting (updates the .exec data).
+    --run, -r       Re-run the owning module's tests before reporting
+                    (refreshes that module's .exec; other modules' existing
+                    execs are still merged in).
     --branches, -b  Show only lines with missed branches (default: show all uncovered).
     --help, -h      Show this help message.
 
 Examples:
     ./scripts/coverage.py juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/conversion/
-    ./scripts/coverage.py juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/conversion/BasicConverter.java
-    ./scripts/coverage.py juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/conversion/ --run
+    ./scripts/coverage.py juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/BitSetFormat.java
+    ./scripts/coverage.py juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/BitSetFormat.java --run
 """
 
+import os
+import re
 import subprocess
 import sys
 import xml.etree.ElementTree as ET
 from pathlib import Path
 
 REPO_ROOT = Path(__file__).resolve().parent.parent
-INTEGRATION_MODULE = REPO_ROOT / "juneau-integration-tests"
-INTEGRATION_EXEC = INTEGRATION_MODULE / "target" / "jacoco.exec"
 
 SRC_MARKERS = ["src/main/java", "src/test/java"]
 
+# Combined, repo-wide JaCoCo XML report assembled from every module's exec file.
+# Lives under target/ (git-ignored); regenerated on every run.
+COMBINED_XML = REPO_ROOT / "target" / "coverage" / "jacoco.xml"
+
+# Modules excluded from the combined report:
+#  - juneau-shaded / juneau-distrib repackage classes from other modules, which would
+#    introduce duplicate-class entries into the JaCoCo report.
+#  - examples / petstore / microservice / sc / test-utils are outside the coverage
+#    scope (mirrors <sonar.coverage.exclusions> in the root pom.xml).
+EXCLUDED_MODULE_MARKERS = (
+    "juneau-shaded",
+    "juneau-distrib",
+    "juneau-examples",
+    "juneau-petstore",
+    "juneau-microservice",
+    "juneau-sc",
+    "juneau-test-utils",
+)
+
 
 def die(msg):
     print(f"ERROR: {msg}", file=sys.stderr)
@@ -83,11 +113,102 @@ def path_to_jacoco_package(path: Path) -> tuple[str | None, str | None]:
     return None, None
 
 
-def run_tests():
-    """Re-run tests with upstream modules to refresh the .exec file."""
-    print("Running tests to refresh coverage data...")
+def discover_modules() -> list[Path]:
+    """Return all build modules (dirs with a pom.xml) excluding the reactor root and excluded markers."""
+    poms = list(REPO_ROOT.glob("*/pom.xml")) + list(REPO_ROOT.glob("*/*/pom.xml"))
+    modules = []
+    for pom in poms:
+        m = pom.parent
+        if m == REPO_ROOT:
+            continue
+        rel = str(m.relative_to(REPO_ROOT))
+        if any(marker in rel for marker in EXCLUDED_MODULE_MARKERS):
+            continue
+        modules.append(m)
+    return sorted(modules)
+
+
+def collect_jacoco_inputs() -> tuple[list[Path], list[Path], list[Path]]:
+    """
+    Scan every active module and collect:
+      - exec files     (target/jacoco.exec)        -- execution data to merge
+      - class dirs      (target/classes)            -- bytecode to analyze
+      - source dirs     (src/main/java)             -- for source attribution
+    A module contributes its classes/sources whenever it has been compiled, and its
+    exec whenever its tests have run.  Coverage of a class is the union of every exec
+    that touched it (its own module's unit tests + the integration suite + any other).
+    """
+    execfiles, classdirs, srcdirs = [], [], []
+    for m in discover_modules():
+        classes = m / "target" / "classes"
+        execf = m / "target" / "jacoco.exec"
+        src = m / "src" / "main" / "java"
+        if classes.is_dir() and any(classes.rglob("*.class")):
+            classdirs.append(classes)
+            if src.is_dir():
+                srcdirs.append(src)
+        if execf.is_file():
+            execfiles.append(execf)
+    return execfiles, classdirs, srcdirs
+
+
+def jacoco_version() -> str:
+    """Read <jacoco.plugin.version> from the root pom (fallback to a known-good default)."""
+    pom = (REPO_ROOT / "pom.xml").read_text(encoding="utf-8")
+    m = re.search(r"<jacoco\.plugin\.version>([^<]+)</jacoco\.plugin\.version>", pom)
+    return m.group(1).strip() if m else "0.8.14"
+
+
+def maven_local_repo() -> Path:
+    """Best-effort resolution of the Maven local repository."""
+    for env in ("MAVEN_REPO", "M2_REPO"):
+        v = os.environ.get(env)
+        if v and Path(v).is_dir():
+            return Path(v)
+    return Path.home() / ".m2" / "repository"
+
+
+def java_binary() -> str:
+    """Resolve a Java launcher, honoring JAVA_HOME, then ~/jdk/default, then PATH."""
+    jh = os.environ.get("JAVA_HOME")
+    if jh and (Path(jh) / "bin" / "java").exists():
+        return str(Path(jh) / "bin" / "java")
+    default_jdk = Path.home() / "jdk" / "default" / "bin" / "java"
+    if default_jdk.exists():
+        return str(default_jdk)
+    return "java"
+
+
+def jacoco_cli_jar() -> Path:
+    """Locate (resolving via Maven if needed) the JaCoCo CLI 'nodeps' jar."""
+    ver = jacoco_version()
+    jar = maven_local_repo() / "org" / "jacoco" / "org.jacoco.cli" / ver / f"org.jacoco.cli-{ver}-nodeps.jar"
+    if jar.exists():
+        return jar
+    print(f"Fetching JaCoCo CLI {ver}...")
+    result = subprocess.run(
+        ["mvn", "-q", "org.apache.maven.plugins:maven-dependency-plugin:3.6.1:get",
+         f"-Dartifact=org.jacoco:org.jacoco.cli:{ver}:jar:nodeps"],
+        cwd=REPO_ROOT, capture_output=True, text=True
+    )
+    if result.returncode != 0 or not jar.exists():
+        print(result.stderr[-2000:], file=sys.stderr)
+        die(f"Could not resolve JaCoCo CLI jar ({jar}).")
+    return jar
+
+
+def run_tests(module: Path):
+    """Re-run a single module's tests to refresh its .exec file.
+
+    Scoped to the owning module so single-file/class queries stay fast.  Other
+    modules' existing execs are still merged in when the report is generated.
+    Requires upstream module artifacts to be available in the local repo (from a
+    prior full build, e.g. `mvn install -DskipTests`).
+    """
+    rel = module.relative_to(REPO_ROOT)
+    print(f"Running tests for module {rel} to refresh coverage data...")
     result = subprocess.run(
-        ["mvn", "-pl", "juneau-integration-tests", "-am", "test", "-Drat.skip=true", "-q"],
+        ["mvn", "-pl", str(rel), "test", "-Drat.skip=true", "-q"],
         cwd=REPO_ROOT,
         capture_output=True,
         text=True
@@ -98,19 +219,30 @@ def run_tests():
     print("Tests passed.\n")
 
 
-def generate_report(module: Path) -> Path:
-    """Generate JaCoCo XML report for the given module using the utest exec file."""
-    xml_path = module / "target" / "site" / "jacoco" / "jacoco.xml"
-    result = subprocess.run(
-        ["mvn", "jacoco:report", f"-Djacoco.dataFile={INTEGRATION_EXEC}", "-q"],
-        cwd=module,
-        capture_output=True,
-        text=True
-    )
+def generate_combined_report() -> Path:
+    """Build the combined, repo-wide JaCoCo XML report from all module execs + classes."""
+    execfiles, classdirs, srcdirs = collect_jacoco_inputs()
+    if not execfiles:
+        die("No jacoco.exec files found in any module. Run with --run, or build the "
+            "project first (e.g. `mvn -pl juneau-integration-tests -am test`).")
+    if not classdirs:
+        die("No compiled classes (target/classes) found. Build the project first.")
+
+    COMBINED_XML.parent.mkdir(parents=True, exist_ok=True)
+    cmd = [java_binary(), "-jar", str(jacoco_cli_jar()), "report"]
+    cmd += [str(e) for e in execfiles]
+    for c in classdirs:
+        cmd += ["--classfiles", str(c)]
+    for s in srcdirs:
+        cmd += ["--sourcefiles", str(s)]
+    cmd += ["--xml", str(COMBINED_XML), "--name", "Juneau combined coverage", "--quiet"]
+
+    print(f"Aggregating coverage from {len(execfiles)} module exec file(s)...")
+    result = subprocess.run(cmd, cwd=REPO_ROOT, capture_output=True, text=True)
     if result.returncode != 0:
-        print(result.stderr[-2000:], file=sys.stderr)
-        die(f"Failed to generate JaCoCo report for {module}.")
-    return xml_path
+        print(result.stderr[-3000:], file=sys.stderr)
+        die("Failed to generate combined JaCoCo report.")
+    return COMBINED_XML
 
 
 def bar(covered, total, width=20):
@@ -145,7 +277,7 @@ def report(xml_path: Path, pkg_filter: str, file_filter: str | None, branches_on
 
     if not matched_packages:
         die(f"No JaCoCo data found for package '{pkg_filter}'.\n"
-            "Make sure the module is built and the exec file is up to date.")
+            "Make sure the module is built and the exec file is up to date (use --run).")
 
     # Collect per-file data
     files_data = []  # list of (pkg_name, fname, lines_with_issues)
@@ -265,13 +397,9 @@ def main():  # NOSONAR: always returns 0 by design — standard POSIX exit code
         die(f"Path does not appear to be under src/main/java or src/test/java: {path}")
 
     if do_run:
-        run_tests()
-
-    if not INTEGRATION_EXEC.exists():
-        die(f"No exec file found at {INTEGRATION_EXEC}. Run with --run first.")
+        run_tests(module)
 
-    print(f"Generating JaCoCo report for module: {module.relative_to(REPO_ROOT)}")
-    xml_path = generate_report(module)
+    xml_path = generate_combined_report()
 
     report(xml_path, pkg_filter, file_filter, branches_only)
     return 0