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

chaokunyang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fory.git


The following commit(s) were added to refs/heads/main by this push:
     new 4c1a5c861 feat(json): support omitting empty properties (#4028)
4c1a5c861 is described below

commit 4c1a5c861ba5f5ea47b9bf32be60c900e9d90862
Author: Shawn Yang <[email protected]>
AuthorDate: Tue Sep 8 12:17:56 2026 +0800

    feat(json): support omitting empty properties (#4028)
    
    ## Why?
    
    
    
    ## What does this PR do?
    
    
    
    ## Related issues
    
    Closes #4024
    
    ## AI Contribution Checklist
    
    
    
    - [ ] Substantial AI assistance was used in this PR: `yes` / `no`
    - [ ] If `yes`, I included a completed [AI Contribution
    
Checklist](https://github.com/apache/fory/blob/main/AI_POLICY.md#9-contributor-checklist-for-ai-assisted-prs)
    in this PR description and the required `AI Usage Disclosure`.
    - [ ] If `yes`, my PR description includes the required `ai_review`
    summary and screenshot evidence or equivalent persisted links of the
    final clean AI review results from both fresh reviewers described in
    `AI_POLICY.md`, the Fory-guided reviewer and the independent general
    reviewer, on the current PR diff or current HEAD after the latest code
    changes.
    
    
    
    ## Does this PR introduce any user-facing change?
    
    
    
    - [ ] Does this PR introduce any public API change?
    - [ ] Does this PR introduce any binary protocol compatibility change?
    
    ## Benchmark
---
 docs/json/annotations.md                           |  27 +-
 docs/json/custom-codecs.md                         |   6 +-
 docs/json/kotlin.md                                |   9 +
 docs/json/object-mapping.md                        |  17 +-
 docs/json/scala.md                                 |   4 +-
 .../org/apache/fory/graalvm/ForyJsonExample.java   |  36 ++
 .../kotlin/json/corpus/PlatformJavaProfileMixin.kt |   3 +-
 .../kotlin/json/corpus/KotlinJsonCorpusTest.kt     |   4 +
 .../processing/JsonTypeProcessorTest.java          |  34 ++
 .../java/org/apache/fory/json/ForyJsonBuilder.java |  35 +-
 .../main/java/org/apache/fory/json/JsonConfig.java |  12 +-
 .../apache/fory/json/annotation/JsonProperty.java  |  14 +-
 .../org/apache/fory/json/codec/ObjectCodec.java    |   9 +-
 .../apache/fory/json/codec/ObjectCodecBuilder.java |  69 ++--
 .../fory/json/codegen/JsonWriterCodegen.java       |  77 ++++-
 .../fory/json/codegen/StringWriterCodegen.java     |   2 +-
 .../fory/json/codegen/Utf8WriterCodegen.java       |   2 +-
 .../org/apache/fory/json/meta/JsonFieldInfo.java   | 137 +++++++-
 .../json/resolver/GeneratedCodecKeyBuilder.java    |   2 +-
 .../fory/json/resolver/JsonSharedRegistry.java     |   9 +-
 .../fory/json/resolver/JsonTypeResolver.java       |   4 +-
 .../fory-json/native-image.properties              |   2 +
 .../json/ForyJsonGraalVMFeatureJarVerifier.java    |   1 +
 .../apache/fory/json/JsonAsyncCompilationTest.java |   3 +-
 .../fory/json/JsonGeneratedCapabilityKeyTest.java  |  20 ++
 .../org/apache/fory/json/JsonInclusionTest.java    | 363 +++++++++++++++++++++
 .../java/org/apache/fory/json/JsonTestSupport.java |   3 +-
 .../json/kotlin/KotlinNullabilityRuntimeTest.kt    |  73 +++++
 .../apache/fory/json/scala/ScalaJsonSuite.scala    |  31 ++
 29 files changed, 935 insertions(+), 73 deletions(-)

diff --git a/docs/json/annotations.md b/docs/json/annotations.md
index d06a0c72b..0e307c2b8 100644
--- a/docs/json/annotations.md
+++ b/docs/json/annotations.md
@@ -154,9 +154,32 @@ public final class User {
 
 The supported inclusion values are:
 
-- `DEFAULT`: use `ForyJsonBuilder.writeNullFields`.
+- `DEFAULT`: use `ForyJsonBuilder.defaultPropertyInclusion` (initially 
`NON_NULL`).
 - `ALWAYS`: write the property even when its selected value is null.
 - `NON_NULL`: omit a null value.
+- `NON_EMPTY`: omit null, zero-length `CharSequence` values (including 
strings) and Java arrays,
+  empty `java.util.Collection` and `java.util.Map` values, and absent
+  JDK `Optional`, `OptionalInt`, `OptionalLong`, and `OptionalDouble` values.
+
+```java
+public final class Response {
+  @JsonProperty(include = JsonProperty.Include.NON_EMPTY)
+  public java.util.List<String> items;
+}
+```
+
+With an empty `items` list, this object writes `{}`. Explicit property 
inclusion overrides the
+builder default. Empty checks apply to the property's logical value before its 
selected codec is
+called. A custom codec that writes an ordinary object as `""` or `{}` does not 
make that object
+empty. An empty `byte[]` is empty with either Base64 or numeric-array 
representation.
+
+Filtering is shallow: `0`, `false`, a list containing null, a list containing 
an empty list, and a
+present Optional containing an empty list remain included. Root values, 
collection elements, Map
+entries, and Any entries are not filtered by property inclusion. Raw JSON 
String properties are
+checked as strings without parsing their text.
+
+Language-specific reconstruction rules still apply; see
+[Kotlin inclusion](kotlin.md#immutable-classes-and-compiler-defaults).
 
 Inclusion affects writing only. A non-default inclusion is invalid for a 
creator-only property with
 no write source. Repeating the same declaration is allowed; conflicting 
explicit names, indexes, or
@@ -168,7 +191,7 @@ order before unindexed properties. Indexes must be 
non-negative, may contain gap
 unique among writable properties. `-1` means unspecified; lower values are 
invalid. An index on a
 setter-only, creator-only, or write-ignored property is invalid.
 
-`NON_EMPTY`, aliases, formatting, and independent read/write names are not 
supported.
+Aliases and independent read/write names are not supported.
 `JsonProperty` cannot be combined with an Any logical property or declared on 
a `JsonAnySetter`.
 
 ## `JsonPropertyOrder`
diff --git a/docs/json/custom-codecs.md b/docs/json/custom-codecs.md
index 5a8fd9ca5..a378cd9ee 100644
--- a/docs/json/custom-codecs.md
+++ b/docs/json/custom-codecs.md
@@ -113,8 +113,10 @@ during a dynamic write. Declared roots and composite child 
types receive `false`
 that needs this distinction after construction must retain the flag for its 
later `resolveTypes`
 call; it must not infer the value from resolver state.
 
-The containing property still controls its name, ignore direction, and 
null-inclusion policy. If a
-null property is omitted, the value codec is not called. If the property is 
emitted, or the value
+The containing property still controls its name, ignore direction, and 
inclusion policy. If a
+property is omitted by `NON_NULL` or `NON_EMPTY`, the value codec is not 
called. `NON_EMPTY` checks
+the logical property value, so an empty List remains empty even with a custom 
List codec. An
+ordinary application object is not considered empty based on the JSON its 
codec writes. If the property is emitted, or the value
 is an array element, collection element, map value, Optional value, or 
atomic-reference value, the
 codec receives and owns null. The registered instance is shared across 
concurrent operations and
 must be thread-safe.
diff --git a/docs/json/kotlin.md b/docs/json/kotlin.md
index 3d9cddedb..37cdb415b 100644
--- a/docs/json/kotlin.md
+++ b/docs/json/kotlin.md
@@ -152,6 +152,15 @@ when the builder's general Java default is to omit null 
fields. An explicit
 `JsonProperty.Include.NON_NULL` on such a property is rejected if omission 
could fail or invoke a
 different compiler default.
 
+The same rule applies to `NON_EMPTY`. A global
+`defaultPropertyInclusion(JsonProperty.Include.NON_EMPTY)` preserves empty 
Kotlin constructor and
+deferred properties, including those declared with `emptyList()` defaults. 
Fory does not compare
+values with compiler defaults or evaluate initializers to decide whether to 
omit them. An explicit
+`NON_EMPTY` annotation is rejected on a reconstructible property whose logical 
type can be empty,
+or whose nullable value would otherwise be omitted. Non-null value classes 
remain present even
+when their underlying string or collection is empty. Use a custom codec for a 
containing model
+that needs a different omission and reconstruction contract.
+
 ## Nullability
 
 Kotlin occurrence nullability is enforced at roots, properties, container 
elements, map values,
diff --git a/docs/json/object-mapping.md b/docs/json/object-mapping.md
index 537c84860..acde2f3b7 100644
--- a/docs/json/object-mapping.md
+++ b/docs/json/object-mapping.md
@@ -212,9 +212,24 @@ original key type. Null map keys are rejected.
 
 ## Builder configuration
 
+To omit empty object properties by default:
+
+```java
+import org.apache.fory.json.ForyJson;
+import org.apache.fory.json.annotation.JsonProperty.Include;
+
+ForyJson json = 
ForyJson.builder().defaultPropertyInclusion(Include.NON_EMPTY).build();
+```
+
+`defaultPropertyInclusion` and `writeNullFields` update the same setting; the 
last call wins.
+The builder accepts `ALWAYS`, `NON_NULL`, and `NON_EMPTY`; `DEFAULT` is only 
valid on a property.
+An explicit `JsonProperty.include` overrides the builder default. See
+[Property inclusion](annotations.md#jsonproperty) for the empty-value 
definitions and boundaries.
+
 | Builder method                         | Default                             
      | User-visible effect                                        |
 | -------------------------------------- | 
----------------------------------------- | 
---------------------------------------------------------- |
-| `writeNullFields(boolean)`             | `false`                             
      | Default inclusion of null object properties                |
+| `defaultPropertyInclusion(Include)`    | `NON_NULL`                          
      | Default inclusion of object properties                     |
+| `writeNullFields(boolean)`             | `false`                             
      | Select `ALWAYS` when true or `NON_NULL` when false         |
 | `writeLongAsString(boolean)`           | `false`                             
      | Write built-in 64-bit integer values as decimal strings    |
 | `withCodegen(boolean)`                 | `true`                              
      | Enable generated object codecs                             |
 | `withAsyncCompilation(boolean)`        | `true`                              
      | Compile generated codecs asynchronously                    |
diff --git a/docs/json/scala.md b/docs/json/scala.md
index dba2334a6..6ece0d007 100644
--- a/docs/json/scala.md
+++ b/docs/json/scala.md
@@ -80,7 +80,9 @@ or values. All other Fory JSON annotations retain the 
behavior described in
 
 If a required non-defaulted reference parameter uses an inclusion rule that 
would omit `null`,
 serialization rejects a null value. This guarantees that JSON written by Fory 
remains readable by
-the same case-class schema.
+the same case-class schema. A global `defaultPropertyInclusion(NON_EMPTY)` 
retains empty values of
+required constructor parameters. An explicit `@JsonProperty(include = 
NON_EMPTY)` is rejected for
+a required parameter whose type can be empty; add a constructor default to 
allow omission.
 
 ## Supported Scala types
 
diff --git 
a/integration_tests/graalvm_tests/src/main/java/org/apache/fory/graalvm/ForyJsonExample.java
 
b/integration_tests/graalvm_tests/src/main/java/org/apache/fory/graalvm/ForyJsonExample.java
index 4d848b569..bcca4a395 100644
--- 
a/integration_tests/graalvm_tests/src/main/java/org/apache/fory/graalvm/ForyJsonExample.java
+++ 
b/integration_tests/graalvm_tests/src/main/java/org/apache/fory/graalvm/ForyJsonExample.java
@@ -92,6 +92,7 @@ public final class ForyJsonExample {
     }
     testModels();
     testConfigurations();
+    testInclusion();
     testCodecs();
     testValueAnnotations();
     testSubtypes();
@@ -149,6 +150,28 @@ public final class ForyJsonExample {
     Preconditions.checkArgument(encoded.contains("Owner") && 
encoded.contains("method"));
   }
 
+  private static void testInclusion() {
+    InclusionValue value = new InclusionValue();
+    String defaults = "{\"items\":[],\"name\":\"\"}";
+    Preconditions.checkArgument(DEFAULT_JSON.toJson(value).equals(defaults));
+    Preconditions.checkArgument(
+        new String(DEFAULT_JSON.toJsonBytes(value), 
StandardCharsets.UTF_8).equals(defaults));
+    ForyJson json =
+        
ForyJson.builder().defaultPropertyInclusion(JsonProperty.Include.NON_EMPTY).build();
+    if (GraalvmSupport.isGraalRuntime()) {
+      exerciseCodegenConfiguration(json, true, true);
+    }
+    Preconditions.checkArgument(json.toJson(value).equals("{}"));
+    Preconditions.checkArgument(
+        new String(json.toJsonBytes(value), 
StandardCharsets.UTF_8).equals("{}"));
+    value.items = List.of("x");
+    value.name = "name";
+    String present = "{\"items\":[\"x\"],\"name\":\"name\"}";
+    Preconditions.checkArgument(json.toJson(value).equals(present));
+    Preconditions.checkArgument(
+        new String(json.toJsonBytes(value), 
StandardCharsets.UTF_8).equals(present));
+  }
+
   private static ForyJson newInterpretedJson() {
     return ForyJson.builder()
         .withPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE)
@@ -715,6 +738,19 @@ public final class ForyJsonExample {
     default ForyJson duplicateConfiguration() {
       return newProviderJson();
     }
+
+    default ForyJson nonEmptyConfiguration() {
+      return 
ForyJson.builder().defaultPropertyInclusion(JsonProperty.Include.NON_EMPTY).build();
+    }
+  }
+
+  @JsonType
+  public static final class InclusionValue {
+    public List<String> items = List.of();
+    public String name = "";
+
+    @JsonProperty(include = JsonProperty.Include.NON_EMPTY)
+    public Optional<String> optional = Optional.empty();
   }
 
   @JsonType
diff --git 
a/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformJavaProfileMixin.kt
 
b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformJavaProfileMixin.kt
index 6739c8e02..6b23dfa9b 100644
--- 
a/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformJavaProfileMixin.kt
+++ 
b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformJavaProfileMixin.kt
@@ -24,5 +24,6 @@ import org.apache.fory.json.annotation.JsonProperty
 
 @JsonMixin(target = PlatformJavaProfile::class)
 public abstract class PlatformJavaProfileMixin {
-  @get:JsonProperty("display_label") public abstract val label: String
+  @get:JsonProperty(value = "display_label", include = 
JsonProperty.Include.NON_EMPTY)
+  public abstract val label: String
 }
diff --git 
a/integration_tests/kotlin_json_corpus/src/test/kotlin/org/apache/fory/integration/kotlin/json/corpus/KotlinJsonCorpusTest.kt
 
b/integration_tests/kotlin_json_corpus/src/test/kotlin/org/apache/fory/integration/kotlin/json/corpus/KotlinJsonCorpusTest.kt
index 280afa067..cceab840a 100644
--- 
a/integration_tests/kotlin_json_corpus/src/test/kotlin/org/apache/fory/integration/kotlin/json/corpus/KotlinJsonCorpusTest.kt
+++ 
b/integration_tests/kotlin_json_corpus/src/test/kotlin/org/apache/fory/integration/kotlin/json/corpus/KotlinJsonCorpusTest.kt
@@ -20,6 +20,7 @@
 package org.apache.fory.integration.kotlin.json.corpus
 
 import org.apache.fory.json.kotlin.ForyJsonKotlin
+import org.testng.Assert.assertEquals
 import org.testng.annotations.Test
 
 public class KotlinJsonCorpusTest {
@@ -31,5 +32,8 @@ public class KotlinJsonCorpusTest {
         .withAsyncCompilation(false)
         .build()
     PlatformCorpusChecks.verifyRoundTrip(json)
+    val empty = PlatformJavaProfile("")
+    assertEquals(json.toJson(empty), "{}")
+    assertEquals(json.toJsonBytes(empty).decodeToString(), "{}")
   }
 }
diff --git 
a/java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java
 
b/java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java
index 6e7659b69..10332218a 100644
--- 
a/java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java
+++ 
b/java/fory-annotation-processor/src/test/java/org/apache/fory/annotation/processing/JsonTypeProcessorTest.java
@@ -506,6 +506,40 @@ public class JsonTypeProcessorTest {
     assertTrue(result.hasGeneratedSource("test/Plain_ForyJsonCodec.java"));
   }
 
+  @Test
+  public void generatedInclusion() throws Exception {
+    CompilationResult result =
+        compile(
+            "test.InclusionModel",
+            "package test;\n"
+                + "import java.util.List;\n"
+                + "import java.util.Collections;\n"
+                + "import org.apache.fory.json.annotation.JsonProperty;\n"
+                + "import org.apache.fory.json.annotation.JsonType;\n"
+                + "@JsonType public final class InclusionModel {\n"
+                + "  @JsonProperty(include = JsonProperty.Include.NON_EMPTY)\n"
+                + "  public List<String> items = Collections.emptyList();\n"
+                + "  public String name = \"\";\n"
+                + "}\n");
+    assertTrue(result.success, result.diagnostics());
+    ClassLoader loader = result.classLoader();
+    Class<?> type = loader.loadClass("test.InclusionModel");
+    GeneratedJsonCodec<?> codec = generatedCodec(loader, 
"test.InclusionModel_ForyJsonCodec");
+    Object value = type.getConstructor().newInstance();
+    assertEquals(
+        fieldAccessor(codec.fieldAccessors(), "items").getObject(value), 
Collections.emptyList());
+    for (boolean codegen : new boolean[] {false, true}) {
+      ForyJson json =
+          ForyJson.builder()
+              .withClassLoader(loader)
+              .withCodegen(codegen)
+              .withAsyncCompilation(false)
+              .build();
+      assertEquals(json.toJson(value), "{\"name\":\"\"}");
+      assertEquals(new String(json.toJsonBytes(value), 
StandardCharsets.UTF_8), "{\"name\":\"\"}");
+    }
+  }
+
   @Test
   public void generatedAccessors() throws Exception {
     CompilationResult result =
diff --git 
a/java/fory-json/src/main/java/org/apache/fory/json/ForyJsonBuilder.java 
b/java/fory-json/src/main/java/org/apache/fory/json/ForyJsonBuilder.java
index 805b64526..2a10f387a 100644
--- a/java/fory-json/src/main/java/org/apache/fory/json/ForyJsonBuilder.java
+++ b/java/fory-json/src/main/java/org/apache/fory/json/ForyJsonBuilder.java
@@ -25,6 +25,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import org.apache.fory.json.annotation.JsonMixin;
+import org.apache.fory.json.annotation.JsonProperty.Include;
 import org.apache.fory.json.codec.JsonValueCodec;
 import org.apache.fory.json.codec.ObjectCodec;
 import org.apache.fory.json.resolver.CodecRegistry;
@@ -49,7 +50,7 @@ import org.apache.fory.platform.GraalvmSupport;
  * discovery but continues to discover eligible instance fields across the 
class hierarchy.
  */
 public final class ForyJsonBuilder {
-  private boolean writeNullFields;
+  private Include defaultPropertyInclusion = Include.NON_NULL;
   private boolean writeLongAsString;
   private boolean codegenEnabled = true;
   private boolean asyncCompilationEnabled = true;
@@ -70,14 +71,34 @@ public final class ForyJsonBuilder {
   ForyJsonBuilder() {}
 
   /**
-   * Sets the default null-inclusion policy for object properties.
+   * Sets the default property inclusion to {@code ALWAYS} when true or {@code 
NON_NULL} when false.
    *
-   * <p>This setting applies only when a logical property's merged {@code 
JsonProperty.include}
-   * value is {@code DEFAULT}. {@code ALWAYS} and {@code NON_NULL} override 
it. Exact custom codecs
-   * own their complete representation and do not observe this 
property-selection setting.
+   * <p>This method and {@link #defaultPropertyInclusion(Include)} update the 
same setting; the last
+   * call wins. Explicit property inclusion overrides the default.
    */
   public ForyJsonBuilder writeNullFields(boolean writeNullFields) {
-    this.writeNullFields = writeNullFields;
+    defaultPropertyInclusion = writeNullFields ? Include.ALWAYS : 
Include.NON_NULL;
+    return this;
+  }
+
+  /**
+   * Sets inclusion for properties whose {@code JsonProperty.include} is 
{@code DEFAULT}.
+   *
+   * <p>The default is {@code NON_NULL}. {@code NON_EMPTY} additionally omits 
empty CharSequence
+   * values, arrays, collections, maps, and absent JDK Optional values. Root 
values and container
+   * entries are not filtered. Inclusion examines the logical property value 
before a custom value
+   * codec runs. Language models retain properties needed for reconstruction.
+   *
+   * @throws IllegalArgumentException if inclusion is {@code DEFAULT}, which 
requires a parent
+   *     default
+   * @throws NullPointerException if inclusion is null
+   */
+  public ForyJsonBuilder defaultPropertyInclusion(Include inclusion) {
+    Objects.requireNonNull(inclusion, "inclusion");
+    if (inclusion == Include.DEFAULT) {
+      throw new IllegalArgumentException("Default property inclusion must be 
concrete");
+    }
+    defaultPropertyInclusion = inclusion;
     return this;
   }
 
@@ -312,7 +333,7 @@ public final class ForyJsonBuilder {
     ModuleInstaller.InstalledModules installed =
         ModuleInstaller.install(new ArrayList<>(modules), codecRegistry, 
mixins);
     return new JsonConfig(
-        writeNullFields,
+        defaultPropertyInclusion,
         writeLongAsString,
         effectiveCodegen,
         effectiveAsyncCompilation,
diff --git a/java/fory-json/src/main/java/org/apache/fory/json/JsonConfig.java 
b/java/fory-json/src/main/java/org/apache/fory/json/JsonConfig.java
index 029c81a58..3adb638f4 100644
--- a/java/fory-json/src/main/java/org/apache/fory/json/JsonConfig.java
+++ b/java/fory-json/src/main/java/org/apache/fory/json/JsonConfig.java
@@ -25,6 +25,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import org.apache.fory.annotation.Internal;
+import org.apache.fory.json.annotation.JsonProperty.Include;
 import org.apache.fory.json.resolver.CodecRegistry;
 
 /**
@@ -36,7 +37,7 @@ import org.apache.fory.json.resolver.CodecRegistry;
 public final class JsonConfig {
   private static final int MAX_CACHED_FIELD_NAMES = 1 << 29;
 
-  private final boolean writeNullFields;
+  private final Include defaultPropertyInclusion;
   private final boolean writeLongAsString;
   private final boolean codegenEnabled;
   private final boolean asyncCompilationEnabled;
@@ -56,7 +57,7 @@ public final class JsonConfig {
   private final JsonTypeCheckContext typeCheckContext;
 
   JsonConfig(
-      boolean writeNullFields,
+      Include defaultPropertyInclusion,
       boolean writeLongAsString,
       boolean codegenEnabled,
       boolean asyncCompilationEnabled,
@@ -73,7 +74,7 @@ public final class JsonConfig {
       JsonCodecFactory[] codecFactories,
       List<String> factoryIdentities,
       JsonTypeChecker typeChecker) {
-    this.writeNullFields = writeNullFields;
+    this.defaultPropertyInclusion = defaultPropertyInclusion;
     this.writeLongAsString = writeLongAsString;
     this.codegenEnabled = codegenEnabled;
     this.asyncCompilationEnabled = asyncCompilationEnabled;
@@ -96,8 +97,9 @@ public final class JsonConfig {
     typeCheckContext = new JsonTypeCheckContext();
   }
 
-  public boolean writeNullFields() {
-    return writeNullFields;
+  /** Returns the concrete default inclusion used for object properties. */
+  public Include defaultPropertyInclusion() {
+    return defaultPropertyInclusion;
   }
 
   /**
diff --git 
a/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonProperty.java
 
b/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonProperty.java
index 4d4308ec7..8e5265d7e 100644
--- 
a/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonProperty.java
+++ 
b/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonProperty.java
@@ -74,7 +74,7 @@ public @interface JsonProperty {
   int index() default INDEX_UNKNOWN;
 
   /**
-   * Returns the null-inclusion policy for this property.
+   * Returns the inclusion policy for this property.
    *
    * <p>The policy affects writing only. A non-default policy is invalid when 
the logical property
    * has no write source, including creator-only input properties. Primitive 
properties are always
@@ -83,13 +83,19 @@ public @interface JsonProperty {
    */
   Include include() default Include.DEFAULT;
 
-  /** Null-inclusion policies supported by Fory JSON. */
+  /** Property inclusion policies supported by Fory JSON. */
   enum Include {
-    /** Inherit the runtime's {@code writeNullFields} setting. */
+    /** Inherit the runtime's default property inclusion. */
     DEFAULT,
     /** Always write the property, including when its value is JSON {@code 
null}. */
     ALWAYS,
     /** Omit the property when its value is Java {@code null}. */
-    NON_NULL
+    NON_NULL,
+    /**
+     * Omit null, empty CharSequence values, arrays, collections, maps, and 
absent JDK Optional
+     * values. Emptiness describes the logical property value before its codec 
runs, not its JSON
+     * output. Container elements and present Optional contents are not 
inspected recursively.
+     */
+    NON_EMPTY
   }
 }
diff --git 
a/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodec.java 
b/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodec.java
index 5084852f4..55e7ee9d6 100644
--- a/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodec.java
+++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodec.java
@@ -33,6 +33,7 @@ import org.apache.fory.collection.ClassValueCache;
 import org.apache.fory.json.ForyJsonException;
 import org.apache.fory.json.PropertyNamingStrategy;
 import org.apache.fory.json.annotation.JsonCodec;
+import org.apache.fory.json.annotation.JsonProperty.Include;
 import org.apache.fory.json.codec.JsonUnwrappedInfo.Declaration;
 import org.apache.fory.json.codec.JsonUnwrappedInfo.Group;
 import org.apache.fory.json.codec.JsonUnwrappedInfo.ReadRoute;
@@ -121,7 +122,7 @@ public class ObjectCodec<T> implements 
CompositeJsonCodec<T> {
       TypeRef<T> ownerType,
       boolean propertyDiscoveryEnabled,
       PropertyNamingStrategy propertyNamingStrategy,
-      boolean writeNullFields,
+      Include defaultPropertyInclusion,
       JsonSharedRegistry sharedRegistry,
       GeneratedJsonCodec<?> generatedCodec) {
     try {
@@ -129,7 +130,7 @@ public class ObjectCodec<T> implements 
CompositeJsonCodec<T> {
           ownerType,
           propertyDiscoveryEnabled,
           propertyNamingStrategy,
-          writeNullFields,
+          defaultPropertyInclusion,
           sharedRegistry,
           generatedCodec);
     } catch (ForyJsonException e) {
@@ -143,7 +144,7 @@ public class ObjectCodec<T> implements 
CompositeJsonCodec<T> {
       TypeRef<T> ownerType,
       boolean propertyDiscoveryEnabled,
       PropertyNamingStrategy propertyNamingStrategy,
-      boolean writeNullFields,
+      Include defaultPropertyInclusion,
       JsonSharedRegistry sharedRegistry,
       GeneratedJsonCodec<?> generatedCodec,
       JsonObjectModel objectModel) {
@@ -152,7 +153,7 @@ public class ObjectCodec<T> implements 
CompositeJsonCodec<T> {
           ownerType,
           propertyDiscoveryEnabled,
           propertyNamingStrategy,
-          writeNullFields,
+          defaultPropertyInclusion,
           sharedRegistry,
           generatedCodec,
           objectModel);
diff --git 
a/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java
 
b/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java
index eb8b70d1d..873a452dd 100644
--- 
a/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java
+++ 
b/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java
@@ -48,6 +48,7 @@ import org.apache.fory.json.annotation.JsonCreator;
 import org.apache.fory.json.annotation.JsonFormat;
 import org.apache.fory.json.annotation.JsonIgnore;
 import org.apache.fory.json.annotation.JsonProperty;
+import org.apache.fory.json.annotation.JsonProperty.Include;
 import org.apache.fory.json.annotation.JsonPropertyOrder;
 import org.apache.fory.json.annotation.JsonRawValue;
 import org.apache.fory.json.annotation.JsonUnwrapped;
@@ -80,14 +81,14 @@ final class ObjectCodecBuilder {
       TypeRef<T> ownerType,
       boolean propertyDiscoveryEnabled,
       PropertyNamingStrategy propertyNamingStrategy,
-      boolean writeNullFields,
+      Include defaultPropertyInclusion,
       JsonSharedRegistry sharedRegistry,
       GeneratedJsonCodec<?> generatedCodec) {
     return build(
         ownerType,
         propertyDiscoveryEnabled,
         propertyNamingStrategy,
-        writeNullFields,
+        defaultPropertyInclusion,
         sharedRegistry,
         generatedCodec,
         null);
@@ -97,7 +98,7 @@ final class ObjectCodecBuilder {
       TypeRef<T> ownerType,
       boolean propertyDiscoveryEnabled,
       PropertyNamingStrategy propertyNamingStrategy,
-      boolean writeNullFields,
+      Include defaultPropertyInclusion,
       JsonSharedRegistry sharedRegistry,
       GeneratedJsonCodec<?> generatedCodec,
       JsonObjectModel objectModel) {
@@ -221,7 +222,11 @@ final class ObjectCodecBuilder {
         if (objectModel != null && builder.anyReadEnabled() && 
builder.creatorArgumentIndex < 0) {
           JsonFieldInfo field =
               builder.build(
-                  record, ownerType, propertyNamingStrategy, writeNullFields, 
generatedCodec);
+                  record,
+                  ownerType,
+                  propertyNamingStrategy,
+                  defaultPropertyInclusion,
+                  generatedCodec);
           anyConstructionIndex = creatorInfo.argumentCount() + 
deferredFields.size();
           deferredFields.add(field);
           deferredRequired.add(builder.requiredDeferred);
@@ -286,7 +291,11 @@ final class ObjectCodecBuilder {
         builder.validateUnwrapped(type, creatorInfo);
         JsonFieldInfo property =
             builder.build(
-                record, ownerType, propertyNamingStrategy, writeNullFields, 
generatedCodec);
+                record,
+                ownerType,
+                propertyNamingStrategy,
+                defaultPropertyInclusion,
+                generatedCodec);
         markRequiredWrite(property, builder, creatorInfo, objectModel);
         int unwrappedConstructionIndex = -1;
         if (creatorInfo != null && builder.creatorArgumentIndex >= 0) {
@@ -317,7 +326,8 @@ final class ObjectCodecBuilder {
         continue;
       }
       JsonFieldInfo field =
-          builder.build(record, ownerType, propertyNamingStrategy, 
writeNullFields, generatedCodec);
+          builder.build(
+              record, ownerType, propertyNamingStrategy, 
defaultPropertyInclusion, generatedCodec);
       markRequiredWrite(field, builder, creatorInfo, objectModel);
       if (!hasAny) {
         FieldBuilder priorProperty = canonicalNames.put(field.name(), builder);
@@ -452,14 +462,35 @@ final class ObjectCodecBuilder {
       FieldBuilder builder,
       JsonCreatorInfo creatorInfo,
       JsonObjectModel objectModel) {
+    int argumentIndex = builder.creatorArgumentIndex;
+    boolean requiredArgument =
+        objectModel != null
+            && creatorInfo != null
+            && argumentIndex >= 0
+            && !creatorInfo.hasDefault(argumentIndex)
+            && builder.hasWriteSource();
+    if (objectModel != null
+        && (field.hasOccurrenceNullability() || requiredArgument)
+        && builder.explicitInclude == Include.NON_EMPTY
+        && field.mayBeEmpty()) {
+      // Validate the logical type even when a value class is lowered to a 
different JVM carrier.
+      throw new ForyJsonException(
+          "Reconstructible JSON property " + field.name() + " cannot omit an 
empty value");
+    }
     if (objectModel != null && field.requiresUnboxedBinding()) {
       // The logical codec is bound only after the recursive parent shell is 
published. Its exact
       // transparent-null action and physical carrier are normalized in 
JsonFieldInfo.resolveTypes.
       return;
     }
     if (objectModel != null && field.hasOccurrenceNullability()) {
+      // Compiler defaults and deferred initializers may differ from an empty 
value. Preserve the
+      // occurrence instead of evaluating application defaults during 
serialization.
+      if (field.omitEmpty()) {
+        field.includeEmptyWrite();
+      }
       if (field.occurrenceNullable()) {
-        if (builder.explicitInclude == JsonProperty.Include.NON_NULL) {
+        if (builder.explicitInclude == Include.NON_NULL
+            || builder.explicitInclude == Include.NON_EMPTY) {
           throw new ForyJsonException(
               "Nullable reconstructible JSON property "
                   + field.name()
@@ -474,14 +505,9 @@ final class ObjectCodecBuilder {
       }
       return;
     }
-    int argumentIndex = builder.creatorArgumentIndex;
-    if (objectModel != null
-        && creatorInfo != null
-        && argumentIndex >= 0
-        && !creatorInfo.hasDefault(argumentIndex)
-        && builder.hasWriteSource()
-        && !field.writeNull()
-        && !field.writeRawType().isPrimitive()) {
+    if (requiredArgument && !field.writeNull() && 
!field.writeRawType().isPrimitive()) {
+      // Language models without occurrence nullability still need every 
non-defaulted argument.
+      field.includeEmptyWrite();
       field.requireNonNullWrite();
     }
   }
@@ -3093,7 +3119,7 @@ final class ObjectCodecBuilder {
         boolean record,
         TypeRef<?> ownerType,
         PropertyNamingStrategy propertyNamingStrategy,
-        boolean defaultWriteNull,
+        Include defaultInclusion,
         GeneratedJsonCodec<?> generatedCodec) {
       validateTypes(ownerType);
       if (explicitInclude != JsonProperty.Include.DEFAULT && 
!hasWriteSource()) {
@@ -3105,11 +3131,10 @@ final class ObjectCodecBuilder {
         throw new ForyJsonException("JSON property name must not be empty for 
" + name);
       }
       Class<?> rawWriteType = hasWriteSource() ? writeRawType() : null;
-      boolean writeNull =
-          rawWriteType != null
-              && (rawWriteType.isPrimitive()
-                  || explicitInclude == JsonProperty.Include.ALWAYS
-                  || explicitInclude == JsonProperty.Include.DEFAULT && 
defaultWriteNull);
+      Include inclusion = explicitInclude == Include.DEFAULT ? 
defaultInclusion : explicitInclude;
+      if (rawWriteType != null && rawWriteType.isPrimitive()) {
+        inclusion = Include.ALWAYS;
+      }
       if (writeGetter != null) {
         writeAccessor = getterAccessor(generatedCodec, writeGetter);
       } else if (writeField != null) {
@@ -3133,7 +3158,7 @@ final class ObjectCodecBuilder {
       }
       return new JsonFieldInfo(
           jsonName,
-          writeNull,
+          inclusion,
           writeField,
           writeGetter,
           readField,
diff --git 
a/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonWriterCodegen.java
 
b/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonWriterCodegen.java
index 92a10d76b..32cf4378d 100644
--- 
a/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonWriterCodegen.java
+++ 
b/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonWriterCodegen.java
@@ -20,17 +20,24 @@
 package org.apache.fory.json.codegen;
 
 import static org.apache.fory.codegen.ExpressionUtils.add;
+import static org.apache.fory.codegen.ExpressionUtils.and;
 import static org.apache.fory.codegen.ExpressionUtils.cast;
 import static org.apache.fory.codegen.ExpressionUtils.eq;
 import static org.apache.fory.codegen.ExpressionUtils.inline;
+import static org.apache.fory.codegen.ExpressionUtils.not;
 
 import java.lang.reflect.Method;
 import java.util.ArrayDeque;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.IdentityHashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalDouble;
+import java.util.OptionalInt;
+import java.util.OptionalLong;
 import org.apache.fory.codegen.Code;
 import org.apache.fory.codegen.CodegenContext;
 import org.apache.fory.codegen.Expression;
@@ -943,7 +950,7 @@ abstract class JsonWriterCodegen {
         }
         expressions.add(
             new Expression.If(
-                ne(value, new Expression.Null(TypeRef.of(String.class), 
false)),
+                presentValue(first, value),
                 present,
                 new Expression.Invoke(writer, "writeObjectStart")));
         firstProperty = 1;
@@ -987,7 +994,7 @@ abstract class JsonWriterCodegen {
       } else {
         expressions.add(member);
       }
-      if (properties[i].writeNull()) {
+      if (properties[i].writeNull() && !properties[i].omitEmpty()) {
         commaKnown = true;
       }
     }
@@ -1036,7 +1043,7 @@ abstract class JsonWriterCodegen {
         expressions.add(written);
         expressions.add(
             new Expression.If(
-                ne(value, new Expression.Null(TypeRef.of(String.class), 
false)),
+                presentValue(first, value),
                 new Expression.ListExpression(
                     fusedStart, new Expression.Assign(written, 
Expression.Literal.ofInt(1))),
                 new Expression.Invoke(writer, "writeObjectStart")));
@@ -1079,7 +1086,7 @@ abstract class JsonWriterCodegen {
         flushAnyMemberGroup(builder, expressions, memberGroup, object, writer);
         expressions.add(member);
       }
-      if (properties[i].writeNull()) {
+      if (properties[i].writeNull() && !properties[i].omitEmpty()) {
         commaKnown = true;
       }
     }
@@ -1246,7 +1253,7 @@ abstract class JsonWriterCodegen {
       return 0;
     }
     for (int i = 0; i < properties.length; i++) {
-      if (properties[i].writeNull()) {
+      if (properties[i].writeNull() && !properties[i].omitEmpty()) {
         return i + 1;
       }
     }
@@ -1300,6 +1307,34 @@ abstract class JsonWriterCodegen {
         new Expression.Variable(
             "v" + id, cast(inline(builder.fieldValue(property, object)), 
TypeRef.of(rawType)));
     Expression nullValue = new Expression.Null(TypeRef.of(rawType), false);
+    if (property.omitEmpty()) {
+      Expression write =
+          isPrefixValue(property.writeKind())
+              ? writeValue(property, id, value, commaKnown, index, writer)
+              : new Expression.ListExpression(
+                  writeFieldName(property, id, commaKnown, index, writer),
+                  writeValue(property, id, value, true, index, writer));
+      Expression present = new Expression.If(nonEmptyValue(property, value), 
write);
+      if (property.writeNull()) {
+        return new Expression.ListExpression(
+            value,
+            new Expression.If(
+                eq(value, nullValue),
+                writeNullField(property, id, commaKnown, index, writer),
+                present));
+      }
+      if (property.requiresNonNullWrite()) {
+        return new Expression.ListExpression(
+            value,
+            new Expression.If(
+                eq(value, nullValue),
+                new Expression.Invoke(fieldRef("wp" + id, 
JsonFieldInfo.class), "rejectNullWrite"),
+                present));
+      }
+      return new Expression.ListExpression(
+          value, new Expression.If(presentValue(property, value), write));
+    }
+
     if (property.writeNull()) {
       JsonFieldKind kind = property.writeKind();
       boolean onlyCodec =
@@ -1358,6 +1393,38 @@ abstract class JsonWriterCodegen {
     return new Expression.ListExpression(value, new Expression.If(ne(value, 
nullValue), write));
   }
 
+  private static Expression presentValue(JsonFieldInfo property, Expression 
value) {
+    Expression present = ne(value, new Expression.Null(value.type(), false));
+    return property.omitEmpty() ? and(present, nonEmptyValue(property, value)) 
: present;
+  }
+
+  private static Expression nonEmptyValue(JsonFieldInfo property, Expression 
value) {
+    Class<?> type = property.writeRawType();
+    if (CharSequence.class.isAssignableFrom(type)) {
+      return ne(
+          new Expression.Invoke(value, "length", 
TypeRef.of(int.class)).inline(),
+          Expression.Literal.ofInt(0));
+    }
+    if (type.isArray()) {
+      return ne(
+          new Expression.FieldValue(value, "length", TypeRef.of(int.class), 
false, true),
+          Expression.Literal.ofInt(0));
+    }
+    if (Collection.class.isAssignableFrom(type) || 
Map.class.isAssignableFrom(type)) {
+      return not(new Expression.Invoke(value, "isEmpty", 
TypeRef.of(boolean.class)).inline());
+    }
+    if (type == Optional.class
+        || type == OptionalInt.class
+        || type == OptionalLong.class
+        || type == OptionalDouble.class) {
+      return new Expression.Invoke(value, "isPresent", 
TypeRef.of(boolean.class)).inline();
+    }
+    return not(
+        new Expression.StaticInvoke(
+                JsonFieldInfo.class, "isEmpty", TypeRef.of(boolean.class), 
value)
+            .inline());
+  }
+
   private Expression writeUnboxed(
       JsonGeneratedCodecBuilder builder,
       JsonFieldInfo property,
diff --git 
a/java/fory-json/src/main/java/org/apache/fory/json/codegen/StringWriterCodegen.java
 
b/java/fory-json/src/main/java/org/apache/fory/json/codegen/StringWriterCodegen.java
index 3bb77ba1e..0a8f8791b 100644
--- 
a/java/fory-json/src/main/java/org/apache/fory/json/codegen/StringWriterCodegen.java
+++ 
b/java/fory-json/src/main/java/org/apache/fory/json/codegen/StringWriterCodegen.java
@@ -102,7 +102,7 @@ final class StringWriterCodegen extends JsonWriterCodegen {
           markUtf16PrefixField(property, commaKnown, fields, i);
         }
       }
-      if (property.writeNull()) {
+      if (property.writeNull() && !property.omitEmpty()) {
         commaKnown = true;
       }
     }
diff --git 
a/java/fory-json/src/main/java/org/apache/fory/json/codegen/Utf8WriterCodegen.java
 
b/java/fory-json/src/main/java/org/apache/fory/json/codegen/Utf8WriterCodegen.java
index 1c49ba284..d996fdb75 100644
--- 
a/java/fory-json/src/main/java/org/apache/fory/json/codegen/Utf8WriterCodegen.java
+++ 
b/java/fory-json/src/main/java/org/apache/fory/json/codegen/Utf8WriterCodegen.java
@@ -131,7 +131,7 @@ final class Utf8WriterCodegen extends JsonWriterCodegen {
           fields.comma[i] = true;
         }
       }
-      if (property.writeNull()) {
+      if (property.writeNull() && !property.omitEmpty()) {
         commaKnown = true;
       }
     }
diff --git 
a/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonFieldInfo.java 
b/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonFieldInfo.java
index 5dac22025..ed9b3c811 100644
--- a/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonFieldInfo.java
+++ b/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonFieldInfo.java
@@ -19,16 +19,23 @@
 
 package org.apache.fory.json.meta;
 
+import java.lang.reflect.Array;
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
 import java.lang.reflect.Type;
 import java.nio.charset.StandardCharsets;
 import java.util.Collection;
 import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalDouble;
+import java.util.OptionalInt;
+import java.util.OptionalLong;
 import org.apache.fory.annotation.Internal;
 import org.apache.fory.json.ForyJsonException;
 import org.apache.fory.json.annotation.JsonCodec;
 import org.apache.fory.json.annotation.JsonFormat;
+import org.apache.fory.json.annotation.JsonProperty.Include;
 import org.apache.fory.json.codec.CodecUtils;
 import org.apache.fory.json.codec.DirectUnboxedValueCodec;
 import org.apache.fory.json.codec.JsonValueCodec;
@@ -85,7 +92,8 @@ public final class JsonFieldInfo {
   private static final int KIND_LONG_AS_STRING = 19;
   private static final int WRITE_NULL_MASK = Integer.MIN_VALUE;
   private static final int REQUIRE_NON_NULL_MASK = 1 << 30;
-  private static final int READ_INDEX_MASK = REQUIRE_NON_NULL_MASK - 1;
+  private static final int OMIT_EMPTY_MASK = 1 << 29;
+  private static final int READ_INDEX_MASK = OMIT_EMPTY_MASK - 1;
   private static final byte[] TRUE_BYTES = 
"true".getBytes(StandardCharsets.ISO_8859_1);
   private static final byte[] FALSE_BYTES = 
"false".getBytes(StandardCharsets.ISO_8859_1);
 
@@ -155,7 +163,7 @@ public final class JsonFieldInfo {
 
   public JsonFieldInfo(
       String name,
-      boolean writeNull,
+      Include inclusion,
       Field writeField,
       Method writeGetter,
       Field readField,
@@ -169,9 +177,9 @@ public final class JsonFieldInfo {
       JsonFormat formatAnnotation,
       boolean rawValue) {
     this.name = name;
-    // Write-null, required-value, and read-index metadata become immutable 
with ObjectCodec.
-    // Packing both flags above the read index avoids enlarging every 
field-metadata object.
-    readIndexAndWriteNull = writeNull ? WRITE_NULL_MASK : 0;
+    // Inclusion, required-value, and read-index metadata become immutable 
with ObjectCodec.
+    // Packing the flags above the read index avoids enlarging every 
field-metadata object.
+    readIndexAndWriteNull = inclusion == Include.ALWAYS ? WRITE_NULL_MASK : 0;
     nameHash = JsonFieldNameHash.hash(name);
     this.writeField = writeField;
     this.writeGetter = writeGetter;
@@ -204,6 +212,11 @@ public final class JsonFieldInfo {
                 : resolvedObjectModelType;
     this.readRawType =
         readTypeRef == null ? null : readUnboxedRequired ? readFallback : 
readTypeRef.getRawType();
+    // A lowered value-class carrier is not the logical property value. An 
empty String carrier
+    // does not make its non-null application value class empty.
+    if (inclusion == Include.NON_EMPTY && !writeUnboxedRequired && 
mayBeEmpty()) {
+      readIndexAndWriteNull |= OMIT_EMPTY_MASK;
+    }
     this.codecAnnotation = codecAnnotation;
     this.valueCodecClass = valueCodecClass;
     this.formatAnnotation = formatAnnotation;
@@ -305,7 +318,7 @@ public final class JsonFieldInfo {
     JsonFieldInfo copy =
         new JsonFieldInfo(
             transformedName,
-            writeNull(),
+            omitEmpty() ? Include.NON_EMPTY : writeNull() ? Include.ALWAYS : 
Include.NON_NULL,
             writeField,
             writeGetter,
             readField,
@@ -319,6 +332,9 @@ public final class JsonFieldInfo {
             formatAnnotation,
             writesRawString());
     copy.setReadIndex(readIndex());
+    if (writeNull()) {
+      copy.includeNullWrite();
+    }
     if (requiresNonNullWrite()) {
       copy.requireNonNullWrite();
     }
@@ -334,11 +350,69 @@ public final class JsonFieldInfo {
     return readIndexAndWriteNull < 0;
   }
 
+  /** Returns whether non-null empty logical values are omitted before writing 
a field token. */
+  public boolean omitEmpty() {
+    return (readIndexAndWriteNull & OMIT_EMPTY_MASK) != 0;
+  }
+
+  /**
+   * Returns whether the logical write type can contain an empty value, 
independently of its
+   * carrier.
+   */
+  public boolean mayBeEmpty() {
+    Class<?> type = writeTypeRef == null ? null : writeTypeRef.getRawType();
+    return type != null
+        && (type.isArray()
+            || CharSequence.class.isAssignableFrom(type)
+            || Collection.class.isAssignableFrom(type)
+            || Map.class.isAssignableFrom(type)
+            || type == Optional.class
+            || type == OptionalInt.class
+            || type == OptionalLong.class
+            || type == OptionalDouble.class
+            || !Modifier.isFinal(type.getModifiers()) && !type.isEnum());
+  }
+
+  /**
+   * Tests a dynamically typed non-null property value, without inspecting its 
JSON representation.
+   * Null omission is handled separately by the containing field's nullability 
contract.
+   */
+  @Internal
+  public static boolean isEmpty(Object value) {
+    if (value instanceof CharSequence) {
+      return ((CharSequence) value).length() == 0;
+    }
+    if (value instanceof Collection) {
+      return ((Collection<?>) value).isEmpty();
+    }
+    if (value instanceof Map) {
+      return ((Map<?, ?>) value).isEmpty();
+    }
+    if (value instanceof Optional) {
+      return !((Optional<?>) value).isPresent();
+    }
+    if (value instanceof OptionalInt) {
+      return !((OptionalInt) value).isPresent();
+    }
+    if (value instanceof OptionalLong) {
+      return !((OptionalLong) value).isPresent();
+    }
+    if (value instanceof OptionalDouble) {
+      return !((OptionalDouble) value).isPresent();
+    }
+    return value != null && value.getClass().isArray() && 
Array.getLength(value) == 0;
+  }
+
   /** Makes a nullable language-model property explicit so output stays 
reconstructible. */
   public void includeNullWrite() {
     readIndexAndWriteNull |= WRITE_NULL_MASK;
   }
 
+  /** Keeps empty language-model properties explicit so output stays 
reconstructible. */
+  public void includeEmptyWrite() {
+    readIndexAndWriteNull &= ~OMIT_EMPTY_MASK;
+  }
+
   /** Returns whether this field carries explicit Kotlin-style occurrence 
nullability. */
   public boolean hasOccurrenceNullability() {
     TypeRef<?> typeRef = writeTypeRef != null ? writeTypeRef : readTypeRef;
@@ -992,8 +1066,7 @@ public final class JsonFieldInfo {
     if (readIndex < 0 || readIndex > READ_INDEX_MASK) {
       throw new IllegalArgumentException("Invalid JSON field read index " + 
readIndex);
     }
-    readIndexAndWriteNull =
-        (readIndexAndWriteNull & (WRITE_NULL_MASK | REQUIRE_NON_NULL_MASK)) | 
readIndex;
+    readIndexAndWriteNull = (readIndexAndWriteNull & ~READ_INDEX_MASK) | 
readIndex;
   }
 
   public JsonTypeInfo writeTypeInfo() {
@@ -1270,6 +1343,9 @@ public final class JsonFieldInfo {
     if (value == null && !writeNull()) {
       return omitNullValue();
     }
+    if (omitEmpty() && isEmpty(value)) {
+      return false;
+    }
     writer.writeFieldName(this, index);
     writeTypeInfo.stringWriter().writeString(writer, value);
     return true;
@@ -1395,6 +1471,9 @@ public final class JsonFieldInfo {
     if (value == null && !writeNull()) {
       return omitNullValue();
     }
+    if (omitEmpty() && value != null && value.isEmpty()) {
+      return false;
+    }
     if (value == null) {
       writer.writeFieldName(this, index);
       writer.writeNull();
@@ -1409,6 +1488,9 @@ public final class JsonFieldInfo {
     if (value == null && !writeNull()) {
       return omitNullValue();
     }
+    if (omitEmpty() && value != null && value.isEmpty()) {
+      return false;
+    }
     writer.writeFieldName(this, index);
     if (value == null) {
       writer.writeNull();
@@ -1423,6 +1505,9 @@ public final class JsonFieldInfo {
     if (value == null && !writeNull()) {
       return omitNullValue();
     }
+    if (omitEmpty() && isEmpty(value)) {
+      return false;
+    }
     if (value == null) {
       writer.writeFieldName(this, index);
       writer.writeNull();
@@ -1438,6 +1523,9 @@ public final class JsonFieldInfo {
     if (value == null && !writeNull()) {
       return omitNullValue();
     }
+    if (omitEmpty() && value != null && Array.getLength(value) == 0) {
+      return false;
+    }
     // Field metadata owns omission only. Once present, the registered codec 
owns null semantics.
     writer.writeFieldName(this, index);
     writeTypeInfo.stringWriter().writeString(writer, value);
@@ -1449,6 +1537,9 @@ public final class JsonFieldInfo {
     if (value == null && !writeNull()) {
       return omitNullValue();
     }
+    if (omitEmpty() && value != null && value.isEmpty()) {
+      return false;
+    }
     writer.writeFieldName(this, index);
     writeTypeInfo.stringWriter().writeString(writer, value);
     return true;
@@ -1459,6 +1550,9 @@ public final class JsonFieldInfo {
     if (value == null && !writeNull()) {
       return omitNullValue();
     }
+    if (omitEmpty() && value != null && value.isEmpty()) {
+      return false;
+    }
     writer.writeFieldName(this, index);
     writeTypeInfo.stringWriter().writeString(writer, value);
     return true;
@@ -1469,6 +1563,9 @@ public final class JsonFieldInfo {
     if (value == null && !writeNull()) {
       return omitNullValue();
     }
+    if (omitEmpty() && isEmpty(value)) {
+      return false;
+    }
     writer.writeFieldName(this, index);
     writeTypeInfo.stringWriter().writeString(writer, value);
     return true;
@@ -1549,6 +1646,9 @@ public final class JsonFieldInfo {
     if (value == null && !writeNull()) {
       return omitNullValue();
     }
+    if (omitEmpty() && isEmpty(value)) {
+      return false;
+    }
     writer.writeFieldName(this, index);
     writeTypeInfo.utf8Writer().writeUtf8(writer, value);
     return true;
@@ -1569,6 +1669,9 @@ public final class JsonFieldInfo {
     if (value == null && !writeNull()) {
       return omitNullValue();
     }
+    if (omitEmpty() && value != null && value.isEmpty()) {
+      return false;
+    }
     if (value == null) {
       writer.writeFieldName(this, index);
       writer.writeNull();
@@ -1596,6 +1699,9 @@ public final class JsonFieldInfo {
     if (value == null && !writeNull()) {
       return omitNullValue();
     }
+    if (omitEmpty() && value != null && value.isEmpty()) {
+      return false;
+    }
     writer.writeFieldName(this, index);
     if (value == null) {
       writer.writeNull();
@@ -1610,6 +1716,9 @@ public final class JsonFieldInfo {
     if (value == null && !writeNull()) {
       return omitNullValue();
     }
+    if (omitEmpty() && isEmpty(value)) {
+      return false;
+    }
     if (value == null) {
       writer.writeFieldName(this, index);
       writer.writeNull();
@@ -1624,6 +1733,9 @@ public final class JsonFieldInfo {
     if (value == null && !writeNull()) {
       return omitNullValue();
     }
+    if (omitEmpty() && value != null && Array.getLength(value) == 0) {
+      return false;
+    }
     // Field metadata owns omission only. Once present, the registered codec 
owns null semantics.
     writer.writeFieldName(this, index);
     writeTypeInfo.utf8Writer().writeUtf8(writer, value);
@@ -1635,6 +1747,9 @@ public final class JsonFieldInfo {
     if (value == null && !writeNull()) {
       return omitNullValue();
     }
+    if (omitEmpty() && value != null && value.isEmpty()) {
+      return false;
+    }
     writer.writeFieldName(this, index);
     writeTypeInfo.utf8Writer().writeUtf8(writer, value);
     return true;
@@ -1645,6 +1760,9 @@ public final class JsonFieldInfo {
     if (value == null && !writeNull()) {
       return omitNullValue();
     }
+    if (omitEmpty() && value != null && value.isEmpty()) {
+      return false;
+    }
     writer.writeFieldName(this, index);
     writeTypeInfo.utf8Writer().writeUtf8(writer, value);
     return true;
@@ -1655,6 +1773,9 @@ public final class JsonFieldInfo {
     if (value == null && !writeNull()) {
       return omitNullValue();
     }
+    if (omitEmpty() && isEmpty(value)) {
+      return false;
+    }
     writer.writeFieldName(this, index);
     writeTypeInfo.utf8Writer().writeUtf8(writer, value);
     return true;
diff --git 
a/java/fory-json/src/main/java/org/apache/fory/json/resolver/GeneratedCodecKeyBuilder.java
 
b/java/fory-json/src/main/java/org/apache/fory/json/resolver/GeneratedCodecKeyBuilder.java
index d6f5f9f52..6102357e3 100644
--- 
a/java/fory-json/src/main/java/org/apache/fory/json/resolver/GeneratedCodecKeyBuilder.java
+++ 
b/java/fory-json/src/main/java/org/apache/fory/json/resolver/GeneratedCodecKeyBuilder.java
@@ -71,7 +71,7 @@ final class GeneratedCodecKeyBuilder {
     keyParts = new ArrayList<>();
     JsonSharedRegistry registry = resolver.sharedRegistry();
     if (!JsonTypeResolver.readerKind(kind)) {
-      keyParts.add(registry.writeNullFields());
+      keyParts.add(registry.defaultPropertyInclusion());
       keyParts.add(registry.writeLongAsString());
     }
     keyParts.add(registry.propertyDiscoveryEnabled());
diff --git 
a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonSharedRegistry.java
 
b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonSharedRegistry.java
index 60fe63ddc..d36c4e501 100644
--- 
a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonSharedRegistry.java
+++ 
b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonSharedRegistry.java
@@ -103,6 +103,7 @@ import org.apache.fory.json.JsonTypeCheckContext;
 import org.apache.fory.json.JsonTypeChecker;
 import org.apache.fory.json.PropertyNamingStrategy;
 import org.apache.fory.json.annotation.JsonCodec;
+import org.apache.fory.json.annotation.JsonProperty.Include;
 import org.apache.fory.json.annotation.JsonSubTypes;
 import org.apache.fory.json.annotation.JsonSubTypes.Inclusion;
 import org.apache.fory.json.annotation.JsonType;
@@ -186,7 +187,7 @@ public final class JsonSharedRegistry {
   private final ExecutorService compilationService;
   private final boolean propertyDiscoveryEnabled;
   private final PropertyNamingStrategy propertyNamingStrategy;
-  private final boolean writeNullFields;
+  private final Include defaultPropertyInclusion;
   private final boolean writeLongAsString;
   private final ClassLoader classLoader;
   private final JsonMixinAnnotations mixinAnnotations;
@@ -243,7 +244,7 @@ public final class JsonSharedRegistry {
     typeCheckCacheLock = typeChecker == null ? null : new Object();
     this.propertyDiscoveryEnabled = config.propertyDiscoveryEnabled();
     propertyNamingStrategy = config.propertyNamingStrategy();
-    writeNullFields = config.writeNullFields();
+    defaultPropertyInclusion = config.defaultPropertyInclusion();
     writeLongAsString = config.writeLongAsString();
     classLoader = config.classLoader();
     mixinAnnotations = new JsonMixinAnnotations(config);
@@ -1217,8 +1218,8 @@ public final class JsonSharedRegistry {
     return propertyNamingStrategy;
   }
 
-  boolean writeNullFields() {
-    return writeNullFields;
+  Include defaultPropertyInclusion() {
+    return defaultPropertyInclusion;
   }
 
   boolean writeLongAsString() {
diff --git 
a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeResolver.java
 
b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeResolver.java
index 5ee441691..8215c900b 100644
--- 
a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeResolver.java
+++ 
b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeResolver.java
@@ -1126,7 +1126,7 @@ public final class JsonTypeResolver {
         ownerType,
         sharedRegistry.propertyDiscoveryEnabled(),
         sharedRegistry.propertyNamingStrategy(),
-        sharedRegistry.writeNullFields(),
+        sharedRegistry.defaultPropertyInclusion(),
         sharedRegistry,
         generatedCodec,
         objectModel);
@@ -2615,7 +2615,7 @@ public final class JsonTypeResolver {
         ownerType,
         sharedRegistry.propertyDiscoveryEnabled(),
         sharedRegistry.propertyNamingStrategy(),
-        sharedRegistry.writeNullFields(),
+        sharedRegistry.defaultPropertyInclusion(),
         sharedRegistry,
         generatedCodec);
   }
diff --git 
a/java/fory-json/src/main/resources/META-INF/native-image/org.apache.fory/fory-json/native-image.properties
 
b/java/fory-json/src/main/resources/META-INF/native-image/org.apache.fory/fory-json/native-image.properties
index 27bcf403d..a14675575 100644
--- 
a/java/fory-json/src/main/resources/META-INF/native-image/org.apache.fory/fory-json/native-image.properties
+++ 
b/java/fory-json/src/main/resources/META-INF/native-image/org.apache.fory/fory-json/native-image.properties
@@ -18,6 +18,7 @@
 # Hosted codegen initializes only Fory JSON implementation classes. The 
retained state classes have
 # no static state and allow an application-owned static ForyJson to be stored 
in the image heap.
 # Application providers, models, and custom codecs retain their own 
initialization policy.
+# Immutable configuration enums are retained by JSON settings and generated 
capability keys.
 # Codec-local chronology formatter holders stay runtime initialized because 
their formatters
 # capture JDK chronology state.
 Args=--features=org.apache.fory.json.ForyJsonGraalVMFeature \
@@ -28,6 +29,7 @@ Args=--features=org.apache.fory.json.ForyJsonGraalVMFeature \
     org.apache.fory.json.JsonConfig,\
     org.apache.fory.json.JsonTypeCheckContext,\
     org.apache.fory.json.PropertyNamingStrategy,\
+    org.apache.fory.json.annotation.JsonProperty$Include,\
     org.apache.fory.util.function.ObjBooleanConsumer,\
     org.apache.fory.util.function.ObjByteConsumer,\
     org.apache.fory.util.function.ObjCharConsumer,\
diff --git 
a/java/fory-json/src/test/java/org/apache/fory/json/ForyJsonGraalVMFeatureJarVerifier.java
 
b/java/fory-json/src/test/java/org/apache/fory/json/ForyJsonGraalVMFeatureJarVerifier.java
index 34317a63d..02fa3a501 100644
--- 
a/java/fory-json/src/test/java/org/apache/fory/json/ForyJsonGraalVMFeatureJarVerifier.java
+++ 
b/java/fory-json/src/test/java/org/apache/fory/json/ForyJsonGraalVMFeatureJarVerifier.java
@@ -63,6 +63,7 @@ public final class ForyJsonGraalVMFeatureJarVerifier {
           + "org.apache.fory.json.JsonConfig,"
           + "org.apache.fory.json.JsonTypeCheckContext,"
           + "org.apache.fory.json.PropertyNamingStrategy,"
+          + "org.apache.fory.json.annotation.JsonProperty$Include,"
           + "org.apache.fory.util.function.ObjBooleanConsumer,"
           + "org.apache.fory.util.function.ObjByteConsumer,"
           + "org.apache.fory.util.function.ObjCharConsumer,"
diff --git 
a/java/fory-json/src/test/java/org/apache/fory/json/JsonAsyncCompilationTest.java
 
b/java/fory-json/src/test/java/org/apache/fory/json/JsonAsyncCompilationTest.java
index 7be44bb4d..5a98ca5b3 100644
--- 
a/java/fory-json/src/test/java/org/apache/fory/json/JsonAsyncCompilationTest.java
+++ 
b/java/fory-json/src/test/java/org/apache/fory/json/JsonAsyncCompilationTest.java
@@ -59,6 +59,7 @@ import org.apache.fory.collection.IdentityMap;
 import org.apache.fory.json.annotation.JsonAnyProperty;
 import org.apache.fory.json.annotation.JsonCodec;
 import org.apache.fory.json.annotation.JsonCreator;
+import org.apache.fory.json.annotation.JsonProperty.Include;
 import org.apache.fory.json.annotation.JsonSubTypes;
 import org.apache.fory.json.annotation.JsonValidator;
 import org.apache.fory.json.codec.ClosedSubtypeCodec;
@@ -1316,7 +1317,7 @@ public class JsonAsyncCompilationTest {
       throws Exception {
     JsonConfig config =
         new JsonConfig(
-            false,
+            Include.NON_NULL,
             false,
             true,
             true,
diff --git 
a/java/fory-json/src/test/java/org/apache/fory/json/JsonGeneratedCapabilityKeyTest.java
 
b/java/fory-json/src/test/java/org/apache/fory/json/JsonGeneratedCapabilityKeyTest.java
index 9caf6b1bd..9f346d3d4 100644
--- 
a/java/fory-json/src/test/java/org/apache/fory/json/JsonGeneratedCapabilityKeyTest.java
+++ 
b/java/fory-json/src/test/java/org/apache/fory/json/JsonGeneratedCapabilityKeyTest.java
@@ -50,6 +50,7 @@ import org.apache.fory.json.annotation.JsonAnyGetter;
 import org.apache.fory.json.annotation.JsonAnySetter;
 import org.apache.fory.json.annotation.JsonCodec;
 import org.apache.fory.json.annotation.JsonMixin;
+import org.apache.fory.json.annotation.JsonProperty.Include;
 import org.apache.fory.json.annotation.JsonSubTypes;
 import org.apache.fory.json.annotation.JsonType;
 import org.apache.fory.json.annotation.JsonUnwrapped;
@@ -368,6 +369,25 @@ public class JsonGeneratedCapabilityKeyTest {
     assertSame(firstType.utf8Reader().getClass(), 
secondType.utf8Reader().getClass());
   }
 
+  @Test
+  public void nonEmptyVersionsWriters() {
+    ForyJson first = ForyJson.builder().withAsyncCompilation(false).build();
+    ForyJson second =
+        ForyJson.builder()
+            .defaultPropertyInclusion(Include.NON_EMPTY)
+            .withAsyncCompilation(false)
+            .build();
+    JsonTypeInfo firstType =
+        JsonTestSupport.currentTypeResolver(first).getTypeInfo(Model.class, 
Model.class);
+    JsonTypeInfo secondType =
+        JsonTestSupport.currentTypeResolver(second).getTypeInfo(Model.class, 
Model.class);
+    assertNotSame(firstType.stringWriter().getClass(), 
secondType.stringWriter().getClass());
+    assertNotSame(firstType.utf8Writer().getClass(), 
secondType.utf8Writer().getClass());
+    assertSame(firstType.latin1Reader().getClass(), 
secondType.latin1Reader().getClass());
+    assertSame(firstType.utf16Reader().getClass(), 
secondType.utf16Reader().getClass());
+    assertSame(firstType.utf8Reader().getClass(), 
secondType.utf8Reader().getClass());
+  }
+
   @Test
   public void fieldModeVersionsClasses() {
     ForyJson properties = 
ForyJson.builder().withAsyncCompilation(false).build();
diff --git 
a/java/fory-json/src/test/java/org/apache/fory/json/JsonInclusionTest.java 
b/java/fory-json/src/test/java/org/apache/fory/json/JsonInclusionTest.java
new file mode 100644
index 000000000..f6e754645
--- /dev/null
+++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonInclusionTest.java
@@ -0,0 +1,363 @@
+/*
+ * 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.fory.json;
+
+import static java.util.Collections.emptyList;
+import static java.util.Collections.emptyMap;
+import static java.util.Collections.emptySet;
+import static java.util.Collections.singletonList;
+import static java.util.Collections.singletonMap;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertThrows;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalDouble;
+import java.util.OptionalInt;
+import java.util.OptionalLong;
+import org.apache.fory.json.annotation.JsonAnyGetter;
+import org.apache.fory.json.annotation.JsonCodec;
+import org.apache.fory.json.annotation.JsonIgnore;
+import org.apache.fory.json.annotation.JsonMixin;
+import org.apache.fory.json.annotation.JsonProperty;
+import org.apache.fory.json.annotation.JsonProperty.Include;
+import org.apache.fory.json.annotation.JsonRawValue;
+import org.apache.fory.json.annotation.JsonUnwrapped;
+import org.apache.fory.json.codec.AbstractJsonValueCodec;
+import org.apache.fory.json.reader.JsonReader;
+import org.apache.fory.json.writer.JsonWriter;
+import org.testng.annotations.Factory;
+import org.testng.annotations.Test;
+
+public class JsonInclusionTest extends ForyJsonTestModels {
+  @Factory(dataProvider = "enableCodegen")
+  public JsonInclusionTest(boolean codegen) {
+    super(codegen);
+  }
+
+  @Test
+  public void dynamicEmptyValues() {
+    ForyJson json = 
newJsonBuilder().defaultPropertyInclusion(Include.NON_EMPTY).build();
+    Dynamic value = new Dynamic();
+    Object[] empty = {
+      null,
+      "",
+      new StringBuilder(),
+      emptyList(),
+      emptySet(),
+      emptyMap(),
+      new boolean[0],
+      new byte[0],
+      new short[0],
+      new char[0],
+      new int[0],
+      new long[0],
+      new float[0],
+      new double[0],
+      new String[0],
+      Optional.empty(),
+      OptionalInt.empty(),
+      OptionalLong.empty(),
+      OptionalDouble.empty()
+    };
+    for (Object item : empty) {
+      value.value = item;
+      assertJson(json, value, "{}");
+    }
+    Object[] present = {
+      0,
+      false,
+      " ",
+      singletonList(null),
+      singletonList(emptyList()),
+      Optional.of(emptyList()),
+      OptionalInt.of(0),
+      OptionalLong.of(0),
+      OptionalDouble.of(0)
+    };
+    String[] encoded = {"0", "false", "\" \"", "[null]", "[[]]", "[]", "0", 
"0", "0.0"};
+    for (int i = 0; i < present.length; i++) {
+      value.value = present[i];
+      assertJson(json, value, "{\"value\":" + encoded[i] + "}");
+    }
+    assertGeneratedWhenSupported(json, Dynamic.class, codegenEnabled());
+  }
+
+  @Test
+  public void propertyOverrides() {
+    Overrides value = new Overrides();
+    assertJson(newJson(), value, 
"{\"always\":[],\"defaults\":[],\"nonNull\":[]}");
+    assertJson(
+        newJsonBuilder().defaultPropertyInclusion(Include.NON_EMPTY).build(),
+        value,
+        "{\"always\":[],\"nonNull\":[]}");
+    value.always = null;
+    value.nonNull = null;
+    assertJson(
+        newJsonBuilder().defaultPropertyInclusion(Include.NON_EMPTY).build(),
+        value,
+        "{\"always\":null}");
+    assertJson(
+        
newJsonBuilder().defaultPropertyInclusion(Include.NON_EMPTY).writeNullFields(true).build(),
+        value,
+        "{\"always\":null,\"defaults\":[]}");
+    assertJson(
+        
newJsonBuilder().writeNullFields(true).defaultPropertyInclusion(Include.NON_EMPTY).build(),
+        value,
+        "{\"always\":null}");
+    assertJson(
+        
newJsonBuilder().defaultPropertyInclusion(Include.NON_EMPTY).writeNullFields(false).build(),
+        value,
+        "{\"always\":null,\"defaults\":[]}");
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> newJsonBuilder().defaultPropertyInclusion(Include.DEFAULT));
+    assertThrows(NullPointerException.class, () -> 
newJsonBuilder().defaultPropertyInclusion(null));
+  }
+
+  @Test
+  public void conditionalFieldPositions() {
+    ForyJson json = 
newJsonBuilder().defaultPropertyInclusion(Include.NON_EMPTY).build();
+    Positions value = new Positions();
+    String[] expected = {
+      "{}",
+      "{\"a\":\"a\"}",
+      "{\"b\":[\"b\"]}",
+      "{\"a\":\"a\",\"b\":[\"b\"]}",
+      "{\"c\":{\"x\":\"c\"}}",
+      "{\"a\":\"a\",\"c\":{\"x\":\"c\"}}",
+      "{\"b\":[\"b\"],\"c\":{\"x\":\"c\"}}",
+      "{\"a\":\"a\",\"b\":[\"b\"],\"c\":{\"x\":\"c\"}}"
+    };
+    for (int i = 0; i < expected.length; i++) {
+      value.a = (i & 1) == 0 ? "" : "a";
+      value.b = (i & 2) == 0 ? emptyList() : singletonList("b");
+      value.c = (i & 4) == 0 ? emptyMap() : singletonMap("x", "c");
+      assertJson(json, value, expected[i]);
+    }
+    assertGeneratedWhenSupported(json, Positions.class, codegenEnabled());
+  }
+
+  @Test
+  public void declaredEmptyValues() {
+    ForyJson json = 
newJsonBuilder().defaultPropertyInclusion(Include.NON_EMPTY).build();
+    Typed value = new Typed();
+    assertJson(json, value, "{\"no\":false,\"zero\":0}");
+    value.bytes = new byte[] {1};
+    value.optional = Optional.of(emptyList());
+    value.raw = "[]";
+    assertJson(
+        json, value, 
"{\"bytes\":\"AQ==\",\"no\":false,\"optional\":[],\"raw\":[],\"zero\":0}");
+    assertGeneratedWhenSupported(json, Typed.class, codegenEnabled());
+  }
+
+  @Test
+  public void getterRunsOnce() {
+    ForyJson json = newJson();
+    Getter value = new Getter();
+    assertEquals(json.toJson(value), "{}");
+    assertEquals(value.calls, 1);
+    value.calls = 0;
+    assertEquals(new String(json.toJsonBytes(value), StandardCharsets.UTF_8), 
"{}");
+    assertEquals(value.calls, 1);
+  }
+
+  @Test
+  public void enumCharSequence() {
+    ForyJson json = 
newJsonBuilder().defaultPropertyInclusion(Include.NON_EMPTY).build();
+    EnumValue value = new EnumValue();
+    assertJson(json, value, "{}");
+    value.value = TextEnum.PRESENT;
+    assertJson(json, value, "{\"value\":\"PRESENT\"}");
+    assertGeneratedWhenSupported(json, EnumValue.class, codegenEnabled());
+  }
+
+  @Test
+  public void customRepresentation() {
+    ForyJson json = 
newJsonBuilder().defaultPropertyInclusion(Include.NON_EMPTY).build();
+    Custom value = new Custom();
+    assertJson(json, value, "{\"object\":\"\"}");
+    value.list = singletonList("x");
+    assertJson(json, value, "{\"list\":\"custom\",\"object\":\"\"}");
+  }
+
+  @Test
+  public void rootAndContents() {
+    ForyJson json = 
newJsonBuilder().defaultPropertyInclusion(Include.NON_EMPTY).build();
+    assertJson(json, emptyList(), "[]");
+    assertJson(json, Arrays.asList(emptyList(), null), "[[],null]");
+    assertJson(json, singletonMap("items", emptyList()), "{\"items\":[]}");
+  }
+
+  @Test
+  public void mixinAndUnwrapped() {
+    ForyJson json = 
newJsonBuilder().registerMixin(PositionsMixin.class).build();
+    Positions value = new Positions();
+    value.a = "";
+    value.b = emptyList();
+    value.c = emptyMap();
+    assertJson(json, value, "{\"a\":\"\",\"c\":{}}");
+    Flattened flattened = new Flattened();
+    flattened.value = value;
+    assertJson(json, flattened, "{\"a\":\"\",\"c\":{}}");
+    ForyJson empty = 
newJsonBuilder().defaultPropertyInclusion(Include.NON_EMPTY).build();
+    assertJson(empty, flattened, "{}");
+    assertJson(empty, new AnyValues(), "{\"items\":[]}");
+  }
+
+  private static void assertJson(ForyJson json, Object value, String expected) 
{
+    assertEquals(json.toJson(value), expected);
+    assertEquals(new String(json.toJsonBytes(value), StandardCharsets.UTF_8), 
expected);
+  }
+
+  public static final class Dynamic {
+    public Object value;
+  }
+
+  public static final class EnumValue {
+    public TextEnum value = TextEnum.EMPTY;
+  }
+
+  public enum TextEnum implements CharSequence {
+    EMPTY(""),
+    PRESENT("text");
+    private final String text;
+
+    TextEnum(String text) {
+      this.text = text;
+    }
+
+    @Override
+    public int length() {
+      return text.length();
+    }
+
+    @Override
+    public char charAt(int index) {
+      return text.charAt(index);
+    }
+
+    @Override
+    public CharSequence subSequence(int start, int end) {
+      return text.subSequence(start, end);
+    }
+  }
+
+  public static final class Overrides {
+    @JsonProperty(include = Include.ALWAYS)
+    public List<String> always = emptyList();
+
+    public List<String> defaults = emptyList();
+
+    @JsonProperty(include = Include.NON_EMPTY)
+    public List<String> nonEmpty = emptyList();
+
+    @JsonProperty(include = Include.NON_NULL)
+    public List<String> nonNull = emptyList();
+  }
+
+  public static final class Positions {
+    public String a;
+    public List<String> b;
+    public Map<String, String> c;
+  }
+
+  @JsonMixin(target = Positions.class)
+  public abstract static class PositionsMixin {
+    @JsonProperty(include = Include.NON_EMPTY)
+    public List<String> b;
+  }
+
+  public static final class Flattened {
+    @JsonUnwrapped public Positions value;
+  }
+
+  public static final class AnyValues {
+    public String a = "";
+
+    @JsonAnyGetter
+    public Map<String, List<String>> values() {
+      return singletonMap("items", emptyList());
+    }
+
+    public String z = "";
+  }
+
+  public static final class Typed {
+    public byte[] bytes = new byte[0];
+    public CharSequence chars = "";
+    public int[] ints = new int[0];
+    public boolean no;
+    public Optional<List<String>> optional = Optional.empty();
+    public OptionalDouble optionalDouble = OptionalDouble.empty();
+    public OptionalInt optionalInt = OptionalInt.empty();
+    public OptionalLong optionalLong = OptionalLong.empty();
+    @JsonRawValue public String raw = "";
+    public String[] strings = new String[0];
+    public int zero;
+  }
+
+  public static final class Getter {
+    @JsonIgnore public int calls;
+
+    @JsonProperty(include = Include.NON_EMPTY)
+    public List<String> getItems() {
+      return calls++ == 0 ? emptyList() : singletonList("changed");
+    }
+  }
+
+  public static final class Custom {
+    @JsonCodec(ListCodec.class)
+    public List<String> list = emptyList();
+
+    @JsonCodec(EmptyObjectCodec.class)
+    public EmptyObject object = new EmptyObject();
+  }
+
+  public static final class EmptyObject {}
+
+  public static final class ListCodec extends 
AbstractJsonValueCodec<List<String>> {
+    @Override
+    public void write(JsonWriter writer, List<String> value) {
+      writer.writeString("custom");
+    }
+
+    @Override
+    public List<String> read(JsonReader reader) {
+      return singletonList(reader.readString());
+    }
+  }
+
+  public static final class EmptyObjectCodec extends 
AbstractJsonValueCodec<EmptyObject> {
+    @Override
+    public void write(JsonWriter writer, EmptyObject value) {
+      writer.writeString("");
+    }
+
+    @Override
+    public EmptyObject read(JsonReader reader) {
+      reader.readString();
+      return new EmptyObject();
+    }
+  }
+}
diff --git 
a/java/fory-json/src/test/java/org/apache/fory/json/JsonTestSupport.java 
b/java/fory-json/src/test/java/org/apache/fory/json/JsonTestSupport.java
index 889d30e71..9942614d5 100644
--- a/java/fory-json/src/test/java/org/apache/fory/json/JsonTestSupport.java
+++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonTestSupport.java
@@ -26,6 +26,7 @@ import java.lang.reflect.Field;
 import java.lang.reflect.Method;
 import java.util.Collections;
 import java.util.Map;
+import org.apache.fory.json.annotation.JsonProperty.Include;
 import org.apache.fory.json.codec.JsonValueCodec;
 import org.apache.fory.json.reader.Latin1JsonReader;
 import org.apache.fory.json.reader.Utf16JsonReader;
@@ -42,7 +43,7 @@ import org.apache.fory.serializer.StringSerializer;
 final class JsonTestSupport {
   private static final JsonConfig CONFIG =
       new JsonConfig(
-          false,
+          Include.NON_NULL,
           false,
           false,
           false,
diff --git 
a/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinNullabilityRuntimeTest.kt
 
b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinNullabilityRuntimeTest.kt
index cbab57cc0..4d47a417e 100644
--- 
a/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinNullabilityRuntimeTest.kt
+++ 
b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinNullabilityRuntimeTest.kt
@@ -77,6 +77,79 @@ class KotlinNullabilityRuntimeTest {
     @get:JsonProperty(include = JsonProperty.Include.NON_NULL) val value: 
String?,
   )
 
+  data class EmptyValues(
+    val required: List<String>,
+    val defaults: List<String> = listOf("default"),
+    val nullable: String? = "default",
+    val optional: Optional<String> = Optional.of("default"),
+  ) {
+    var deferred: String = "initializer"
+  }
+
+  data class InvalidEmptyOmission(
+    @get:JsonProperty(include = JsonProperty.Include.NON_EMPTY)
+    val value: List<String> = emptyList(),
+  )
+
+  class InvalidDeferredOmission {
+    @get:JsonProperty(include = JsonProperty.Include.NON_EMPTY) var value: 
String = "initializer"
+  }
+
+  @JvmInline value class EmptyText(val value: String)
+
+  data class ValueClassOmission(
+    @get:JsonProperty(include = JsonProperty.Include.NON_EMPTY) val value: 
EmptyText,
+  )
+
+  @JvmInline value class TextSequence(val value: String) : CharSequence by 
value
+
+  data class SequenceOmission(
+    @get:JsonProperty(include = JsonProperty.Include.NON_EMPTY) val value: 
TextSequence,
+  )
+
+  data class GenericOmission<T>(
+    @get:JsonProperty(include = JsonProperty.Include.NON_EMPTY) val value: T,
+  )
+
+  @Test
+  fun reconstructibleEmptyOmission() {
+    for (codegen in listOf(false, true)) {
+      val json =
+        ForyJsonKotlin.builder()
+          .withCodegen(codegen)
+          .withAsyncCompilation(false)
+          .defaultPropertyInclusion(JsonProperty.Include.NON_EMPTY)
+          .build()
+      val value = EmptyValues(emptyList(), emptyList(), null, Optional.empty())
+      value.deferred = ""
+      val type = jsonTypeRef<EmptyValues>()
+      val text = 
"""{"required":[],"defaults":[],"nullable":null,"optional":null,"deferred":""}"""
+      assertEquals(text, json.toJson(value, type))
+      assertEquals(text, json.toJsonBytes(value, type).decodeToString())
+      for (decoded in listOf(json.fromJson(text, type), 
json.fromJson(text.toByteArray(), type))) {
+        assertEquals(value, decoded)
+        assertEquals("", decoded.deferred)
+      }
+      val wrapped = ValueClassOmission(EmptyText(""))
+      val wrappedType = jsonTypeRef<ValueClassOmission>()
+      assertEquals("""{"value":""}""", json.toJson(wrapped, wrappedType))
+      assertEquals(wrapped, json.fromJson(json.toJsonBytes(wrapped, 
wrappedType), wrappedType))
+      val generic = GenericOmission(EmptyText(""))
+      val genericType = jsonTypeRef<GenericOmission<EmptyText>>()
+      assertEquals("""{"value":""}""", json.toJson(generic, genericType))
+      assertEquals(generic, json.fromJson(json.toJsonBytes(generic, 
genericType), genericType))
+      assertFailsWith<ForyJsonException> { json.toJson(InvalidEmptyOmission()) 
}
+      assertFailsWith<ForyJsonException> { 
json.toJsonBytes(InvalidDeferredOmission()) }
+      assertFailsWith<ForyJsonException> { 
json.toJson(SequenceOmission(TextSequence(""))) }
+      assertFailsWith<ForyJsonException> {
+        json.toJsonBytes(
+          GenericOmission(TextSequence("")),
+          jsonTypeRef<GenericOmission<TextSequence>>()
+        )
+      }
+    }
+  }
+
   @Test
   fun rootNullability() {
     forEachJsonMode { json ->
diff --git 
a/scala/fory-json-scala/src/test/scala/org/apache/fory/json/scala/ScalaJsonSuite.scala
 
b/scala/fory-json-scala/src/test/scala/org/apache/fory/json/scala/ScalaJsonSuite.scala
index c8c67a8c1..80cf2a791 100644
--- 
a/scala/fory-json-scala/src/test/scala/org/apache/fory/json/scala/ScalaJsonSuite.scala
+++ 
b/scala/fory-json-scala/src/test/scala/org/apache/fory/json/scala/ScalaJsonSuite.scala
@@ -95,6 +95,16 @@ object MethodLocalHolder {
 
 case class NullableRequired(value: String)
 
+case class EmptyRequired(value: String, items: java.util.List[String], 
numbers: Array[Int])
+
+case class ExplicitEmptyRequired(
+    @JsonProperty(include = JsonProperty.Include.NON_EMPTY) value: String
+)
+
+case class EmptyDefault(
+    @JsonProperty(include = JsonProperty.Include.NON_EMPTY) value: String = ""
+)
+
 case class UserId(value: Int) extends AnyVal
 
 case class LongId(value: Long) extends AnyVal
@@ -337,6 +347,27 @@ class ScalaJsonSuite extends AnyFunSuite {
     }
   }
 
+  test("required constructor values retain empty properties") {
+    for (codegen <- Seq(false, true)) {
+      val json = ForyJsonScala.builder()
+        .withCodegen(codegen)
+        .withAsyncCompilation(false)
+        .defaultPropertyInclusion(JsonProperty.Include.NON_EMPTY)
+        .build()
+      val value = EmptyRequired("", new java.util.ArrayList[String](), 
Array.emptyIntArray)
+      val text = json.toJson(value)
+      assert(text == "{\"value\":\"\",\"items\":[],\"numbers\":[]}")
+      assert(new String(json.toJsonBytes(value), UTF_8) == text)
+      val decoded = json.fromJson(text, classOf[EmptyRequired])
+      assert(decoded.value == "")
+      assert(decoded.items.isEmpty)
+      assert(decoded.numbers.isEmpty)
+      assertThrows[ForyJsonException](json.toJson(ExplicitEmptyRequired("")))
+      assert(json.toJson(EmptyDefault()) == "{}")
+      assert(json.fromJson("{}", classOf[EmptyDefault]) == EmptyDefault())
+    }
+  }
+
   test("declared Scala collection and algebraic types") {
     val json = ForyJsonScala.builder().withCodegen(false).build()
     val listType = new TypeRef[List[Int]]() {}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to