Repository: FasterXML/jackson-databind (2026-04-19T03:42:14Z)

Issue:
When using type id with `As.EXTERNAL_PROPERTY` together with `@JsonValue` inside type the serialiser omits external type id field from result when `@JsonValue` value is null
**Describe the bug**

When using type id with `As.EXTERNAL_PROPERTY` together with @JsonValue inside type the serialiser omits external type id field from result when @JsonValue value is null.

**Version information**
2.13.3

**To Reproduce**
```java

package org.example;

import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonSubTypes.Type;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonTypeInfo.As;
import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;
import com.fasterxml.jackson.annotation.JsonValue;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

public class Main {
  public static interface GenericType {
    String getValue();
    void setValue(String value);
  }

  public static abstract class AbstractGenericType implements GenericType
  {
    protected String value;
    public AbstractGenericType() {}
    public AbstractGenericType(String value) {this.value = value;}
    @Override @JsonValue public String getValue() {return value;}
    @Override @JsonValue public void setValue(String value) {this.value = value;}
  }

  public static class FooType extends AbstractGenericType {
    public FooType() {super();}
    public FooType(String value) {super(value);}
  }
  public static class Container {
    @JsonTypeInfo(use = Id.MINIMAL_CLASS, include = As.EXTERNAL_PROPERTY, property = "type", visible = true)
    @JsonSubTypes(value = {@Type(value = FooType.class)})
    protected GenericType value;

    public GenericType getValue() { return value; }
    public void setValue(GenericType value) { this.value = value; }
  }

  public static void main(String[] args) throws JsonProcessingException {
    var om = new ObjectMapper();

    var container = new Container();

    System.out.println(om.writeValueAsString(container)); // {"value":null}
    // Its fine as container::value == null so external type id field is missing

    container.setValue(new FooType());

    System.out.println(om.writeValueAsString(container)); // {"value":null}
    // Its not fine as container::value != null, external type id field should be set ".Main$FooType" value

    container.setValue(new FooType("foobar"));

    System.out.println(om.writeValueAsString(container)); // {"value":"foobar","type":".Main$FooType"}
    // Now external type id field is present
  }
}
```

**Expected behavior**

Expected that external type id field should be present with type id value when type is not null. 

P.S. It would be also great if it was possible to still include external type id field with for e.g. `null` value when type is null.




Discussion:
I think I found a place where this situation happens.

This code helps to work with it:

```java
// and if we got null, can also just write it directly
JsonSerializer<Object> ser = _valueSerializer;
if (value == null) {
    ctxt.defaultSerializeNull(gen);

    if (bean != null && ser != null) {
        if (_forceTypeInformation) {
            // Confusing? Type id is for POJO and NOT for value returned by JsonValue accessor...
            WritableTypeId typeIdDef = typeSer0.writeTypePrefix(gen,
                typeSer0.typeId(bean, JsonToken.VALUE_STRING));
            typeSer0.writeTypeSuffix(gen, typeIdDef);
        }
    }

    return;
}
```

For this spot:

https://github.com/FasterXML/jackson-databind/blob/aebeedfdc22e0068051a45c318b9ece838958a33/src/main/java/com/fasterxml/jackson/databind/ser/std/JsonValueSerializer.java#L271-L276

I managed to make it run locally, and it worked as expected, but I couldn't make the when `type == null` to also print as it doesn't enter that function at all. Sorry I am still pretty noob with Java
Ok. Just to save you some trouble: I suspect you can not really combine use of `@JsonValue` and `As.EXTERNAL_PROPERTY` inclusion mechanism. I don't think this will work. Use of other inclusion modes might work, although the combination of polymorphic deserialization and `@JsonValue` itself is problematic.
The issue is with handling of delegating needed for serialization of "delegate" value (in place of the original value); and the question of identity, in a way; does type information relate to underlying true type, or delegate?

If I remember code correctly, it assumes former ("true" / actual Java type; NOT one returned by `@JsonValue` annotated getter or field). And the problem them becomes of trying to stitch together type (de)serializer for that type with actual content (de)serializer for delegate type -- this combination can get tricky if and when types of true/delegate are very different (like POJO vs String).

I think it would be good if there was a way to indicate clearly that this combination cannot work -- throw an exception in case where use is attempted, describing the problematic combination -- and I'd be happy to get a patch for this.
But I am not confident that the combination itself can be supported.

Then again if you (or anyone else) manages to show I am wrong here, would be happy to be proven wrong :)

@cowtowncoder Thanks for the reply, I am having my own custom type id resolver with introspector and it works really well, with `@JsonValue` just the condition I mentioned about then `@JsonValue` value is null. I can provide some code as I had some issues before making other functionality.

But maybe the case is with standard annotation `@JsonTypeInfo` implementation.

![image](https://user-images.githubusercontent.com/15481/181063393-02c55cd4-ee21-452c-be7c-8aef18a65a9e.png)

Oh maybe important tip is that `@JsonValue` is not on generic type, but a property inside the type. In my screenshot it's `TextValueType` has get/set value with `@JsonValue` attribute.
Hmmh. I see. It might actually be ok for `@JsonValue` to be within surrogate value.

One thing to note, which may not matter a lot -- `@JsonValue` only ever affects serialization so there's no need to add it to getter and setter (actually it should never be needed on both; annotations are combined).
But for deserialization you typically would need matching `@JsonCreator` on String-taking constructor, to maintain structural equality (that is, that serialization as JSON String, here, can be deserialized).
But I don't think that's the problem here.

What is likely more problematic, however, is that Java/JSON String is considered a "natural" type. So serialization of `String` value never takes in Type Id -- other "natural" types are Booleans, Longs and Doubles.
Specifically, on deserialization, seeing a plain JSON String is probably taken as indication of there not being any Type Id available. For other inclusion types this makes sense (only JSON Objects can have `As.PROPERTY`/`As.EXISTING_PROPERTY`; `WRAPPER_ARRAY` and `WRAPPER_OBJECT` only work with matchng types), but for `EXTERNAL_PROPERTY` this is not valid logic as the Type Id would be outside ("external") of the polymorphic value itself.

So it is likely that use of plain (JSON) `String` as serialization specifically causes problems. Not sure whether that helps in determining whether usage could be made to work or not.

Oh and forgot to outline the "why isn't Type Id" written part: that is due to String being "natural" type.
Given that this does not actually make sense for specific case of `EXTERNAL_PROPERTY`, perhaps the rule for that inclusion type could be changed, as a special case.

I would be slightly worried about regression for existing usage (since this does change observed behavior and quite often someone, somewhere, is relying on whatever the current behavior is at given point), although as a starting point, if a change solves your problem and does NOT fail any of existing unit test, it's a credible fix candidate.




I see, maybe there is a way to make something like `@JsonValue` or replace its serialisation logic? Because I know this can cause a lot of changes for people who expected to work like before. 

BTW this all String is just simple example, I am trying out
Handling of `@JsonValue` can be replaced (there is specific `JsonSerializer` implementation for it), but that is probably quite a bit of work. And it is still necessary to handle deserialization as well which might need changes as well.

One thing that could help here is an actual unit test -- code above has much of what is needed, but instead of print statements, it would need to assert specific results: that is, to show what should happen (successful reading and writing), which fails. At least that'd make it easy to see if handling could be made to work.

I will try to make a unit test, but I am having bit hectic week so probably only next week. I figured the `@JsonValue` Serializer and used `SerializerModifier` to replace with my own `JsonValueSerializer`.
@aurimasniekis np at all. I know how limited time can be. Glad that you were able to figure out a work-around at least; that sounds like a good approach to solve your immediate needs fwtw.



Patch:
diff --git a/release-notes/CREDITS b/release-notes/CREDITS
index acec9aa881..0a7bd42102 100644
--- a/release-notes/CREDITS
+++ b/release-notes/CREDITS
@@ -426,6 +426,12 @@ Daniel Gerhardt (@dgerhardt)
  * Reported #3205: ConfigOverrides for merging are not applied for items inside containers
   [3.2.0]
 
+Aurimas Niekis (@aurimasniekis)
+ * Reported #3547: When using type id with `As.EXTERNAL_PROPERTY` together with
+  `@JsonValue` inside type the serialiser omits external type id field
+  from result when `@JsonValue` value is null
+  [3.2.0]
+
 Andrzej Leśkiewicz (@aleskiewicz)
  * Reported #3573: Inconsistent handling of nil `UUID` in property inclusion
   [3.2.0]
diff --git a/release-notes/VERSION b/release-notes/VERSION
index 9e4aeb1e07..5063ff13d6 100644
--- a/release-notes/VERSION
+++ b/release-notes/VERSION
@@ -126,6 +126,11 @@ Versions: 3.x (for earlier see VERSION-2.x)
   "Cannot construct instance (although at least one Creator exists)"
  (reported by @dinopraso)
  (fix by @JacksonJang)
+#3547: When using type id with `As.EXTERNAL_PROPERTY` together with
+  `@JsonValue` inside type the serialiser omits external type id field
+  from result when `@JsonValue` value is null
+ (reported by Aurimas N)
+ (fix by @cowtowncoder, w/ Claude code)
 #3573: Inconsistent handling of nil `UUID` in property inclusion
  (reported by Andrzej L)
 #3591: Ignored fields not consistently exposed via
diff --git a/src/main/java/tools/jackson/databind/ser/jackson/JsonValueSerializer.java b/src/main/java/tools/jackson/databind/ser/jackson/JsonValueSerializer.java
index 7d91917207..3fbdbdc155 100644
--- a/src/main/java/tools/jackson/databind/ser/jackson/JsonValueSerializer.java
+++ b/src/main/java/tools/jackson/databind/ser/jackson/JsonValueSerializer.java
@@ -274,6 +274,24 @@ public void serializeWithType(Object bean, JsonGenerator gen, SerializationConte
         }
         // and if we got null, can also just write it directly
         if (value == null) {
+            // [databind#3547]: With `EXTERNAL_PROPERTY` inclusion the type id lives on
+            // the parent object, so even if the `@JsonValue` accessor returns null we
+            // must still emit the type id (derived from the non-null bean) so the parent
+            // writes its external "type" property.
+            //
+            // Scoped to EXTERNAL_PROPERTY only: the parent has already committed to a
+            // sibling "type" field, so dropping it leaves the container in a
+            // schema-inconsistent state. Wrapper modes (WRAPPER_ARRAY / WRAPPER_OBJECT)
+            // have the same roundtrip gap in principle, but changing those would alter
+            // the JSON wire format for null values (bare `null` vs. `["id",null]`) and
+            // risk regressing callers relying on the current output.
+            if (typeSer0.getTypeInclusion() == As.EXTERNAL_PROPERTY) {
+                WritableTypeId typeIdDef = typeSer0.writeTypePrefix(gen, ctxt,
+                        typeSer0.typeId(bean, JsonToken.VALUE_NULL));
+                ctxt.defaultSerializeNullValue(gen);
+                typeSer0.writeTypeSuffix(gen, ctxt, typeIdDef);
+                return;
+            }
             ctxt.defaultSerializeNullValue(gen);
             return;
         }


Test patch:
diff --git a/src/test/java/tools/jackson/databind/jsontype/ext/ExternalPropertyWithJsonValueNull3547Test.java b/src/test/java/tools/jackson/databind/jsontype/ext/ExternalPropertyWithJsonValueNull3547Test.java
new file mode 100644
index 0000000000..32532c0c33
--- /dev/null
+++ b/src/test/java/tools/jackson/databind/jsontype/ext/ExternalPropertyWithJsonValueNull3547Test.java
@@ -0,0 +1,105 @@
+package tools.jackson.databind.jsontype.ext;
+
+import org.junit.jupiter.api.Test;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonSubTypes.Type;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.annotation.JsonTypeInfo.As;
+import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;
+import com.fasterxml.jackson.annotation.JsonValue;
+
+import tools.jackson.databind.ObjectMapper;
+import tools.jackson.databind.testutil.DatabindTestUtil;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+// [databind#3547]: When combining `As.EXTERNAL_PROPERTY` with `@JsonValue`, the serializer
+// used to omit the external type id field when the `@JsonValue` return value was null.
+public class ExternalPropertyWithJsonValueNull3547Test extends DatabindTestUtil
+{
+    public interface GenericType {
+        String getValue();
+        void setValue(String value);
+    }
+
+    public static abstract class AbstractGenericType implements GenericType {
+        protected String value;
+        public AbstractGenericType() { }
+        public AbstractGenericType(String value) { this.value = value; }
+        @Override @JsonValue public String getValue() { return value; }
+        @Override public void setValue(String value) { this.value = value; }
+    }
+
+    public static class FooType extends AbstractGenericType {
+        public FooType() { super(); }
+        @JsonCreator public FooType(String value) { super(value); }
+    }
+
+    public static class Container {
+        @JsonTypeInfo(use = Id.CLASS, include = As.EXTERNAL_PROPERTY, property = "type", visible = true)
+        @JsonSubTypes(value = { @Type(value = FooType.class) })
+        protected GenericType value;
+
+        public GenericType getValue() { return value; }
+        public void setValue(GenericType value) { this.value = value; }
+    }
+
+    private final ObjectMapper MAPPER = newJsonMapper();
+
+    @Test
+    public void serializeWithNonNullJsonValue() throws Exception {
+        Container container = new Container();
+        container.setValue(new FooType("foobar"));
+        String json = MAPPER.writeValueAsString(container);
+        assertEquals(
+            "{\"value\":\"foobar\",\"type\":\"" + FooType.class.getName() + "\"}",
+            json);
+    }
+
+    // The type id must still be emitted even when the `@JsonValue` accessor returns null,
+    // as long as the containing bean itself is non-null.
+    @Test
+    public void serializeWithNullJsonValueShouldStillEmitTypeId() throws Exception {
+        Container container = new Container();
+        container.setValue(new FooType()); // value bean present, but @JsonValue getter returns null
+        String json = MAPPER.writeValueAsString(container);
+        assertEquals(
+            "{\"value\":null,\"type\":\"" + FooType.class.getName() + "\"}",
+            json);
+    }
+
+    // Sanity check: if the bean itself is null, no type id should be emitted.
+    @Test
+    public void serializeWithNullBean() throws Exception {
+        Container container = new Container();
+        String json = MAPPER.writeValueAsString(container);
+        assertEquals("{\"value\":null}", json);
+    }
+
+    @Test
+    public void deserializeWithNonNullJsonValue() throws Exception {
+        String json = "{\"value\":\"foobar\",\"type\":\"" + FooType.class.getName() + "\"}";
+        Container container = MAPPER.readValue(json, Container.class);
+        assertNotNull(container.value);
+        assertInstanceOf(FooType.class, container.value);
+        assertEquals("foobar", container.value.getValue());
+    }
+
+    @Test
+    public void roundtripWithNonNullJsonValue() throws Exception {
+        Container in = new Container();
+        in.setValue(new FooType("foobar"));
+        String json = MAPPER.writeValueAsString(in);
+        Container out = MAPPER.readValue(json, Container.class);
+        assertInstanceOf(FooType.class, out.value);
+        assertEquals("foobar", out.value.getValue());
+    }
+
+    // 18-Apr-2026, tatu: Note: no deser test for surrogate `null` value as
+    //   it is not quite clear what should happen (or rather, how to make
+    //   round-trip handling work as expected)
+}