Skip to content
Open
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: fix
packages:
- "@typespec/http-client-java"
---

Prevent duplicate Java discriminator members while preserving inherited discriminators in stream-style XML serialization.
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public final class GoblinShark extends Shark {
* Discriminator property for Fish.
*/
@Metadata(properties = { MetadataProperties.GENERATED })
private String kind = "shark";
private final String kind = "shark";

/*
* The sharktype property.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public final class SawShark extends Shark {
* Discriminator property for Fish.
*/
@Metadata(properties = { MetadataProperties.GENERATED })
private String kind = "shark";
private final String kind = "shark";

/*
* The sharktype property.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public class Shark extends Fish {
* Discriminator property for Fish.
*/
@Metadata(properties = { MetadataProperties.GENERATED })
private String kind = "shark";
private final String kind = "shark";

/*
* The sharktype property.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
* Manages metadata about properties in a {@link ClientModel} and how they correlate with model class generation.
Expand Down Expand Up @@ -124,10 +125,13 @@ public ClientModelPropertiesManager(ClientModel model, JavaSettings settings) {
xmlRootElementNamespace = model.getXmlNamespace();
}

Set<String> thisModelPropertySerializeNames = model.getProperties()
.stream()
Set<String> thisModelPropertySerializeNames = Stream.concat(
// discriminator property is known to be redefined in subclass
.filter(property -> !property.isPolymorphicDiscriminator())
model.getProperties().stream().filter(property -> !property.isPolymorphicDiscriminator()),
// For example, after a child's fixed "type" property is removed from model.getProperties(), the fixed
// "type" entry in model.getParentPolymorphicDiscriminators() is included here so the inherited parent
// "type" property is masked. Otherwise, the generated child has two "type" members.
model.getParentPolymorphicDiscriminators().stream())
Comment on lines +128 to +134

@XiaofeiCao Xiaofei Cao (XiaofeiCao) Sep 7, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this fixed another edge case(exists before, but exposed by our fix), covered in https://github.com/XiaofeiCao/typespec/blob/a9044e57522bc213f38893a035a6f645afb91bf8/packages/http-client-java/generator/http-client-generator-test/tsp/discriminator-edge-cases.tsp#L33-L39

We remove the child-declared "discriminator" now in this PR and move it into child's parentDiscriminators. Currently child will generate parentDiscriminators as local fields, thus we need to add them back here in thisModelPropertySerializeNames to prevent duplicated field declarations(which will count in parent's shaded ones).

.map(ClientModelProperty::getSerializedName)
.filter(name -> Objects.nonNull(name) && !name.isEmpty())
.collect(Collectors.toSet());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import com.microsoft.typespec.http.client.generator.core.model.javamodel.JavaFile;
import com.microsoft.typespec.http.client.generator.core.model.javamodel.JavaVisibility;
import com.microsoft.typespec.http.client.generator.core.util.ClientModelUtil;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.Function;

Expand Down Expand Up @@ -116,7 +117,13 @@ private static void declareFieldInternal(ClientModelProperty discriminator, Clie
&& settings.isShareJsonSerializableCode()) {
classBlock.memberVariable(JavaVisibility.PackagePrivate, fieldSignature);
} else if (!allPolymorphicModelsInSamePackage || !settings.isShareJsonSerializableCode()) {
classBlock.privateMemberVariable(fieldSignature);
// Active discriminators stay mutable to preserve unknown values during fallback deserialization.
if (discriminator.isConstant()
&& !Objects.equals(discriminator.getSerializedName(), model.getPolymorphicDiscriminatorName())) {
classBlock.privateFinalMemberVariable(fieldSignature);
} else {
classBlock.privateMemberVariable(fieldSignature);
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -363,11 +363,13 @@ public ClientModel map(ObjectSchema compositeType) {
// the correct serialization in multi-level polymorphic structures.
for (ClientModel derivedType : derivedTypes) {
if (!Objects.equals(polymorphicDiscriminator, derivedType.getPolymorphicDiscriminatorName())) {
// The child hierarchy stays in one fixed parent discriminator branch.
ClientModelProperty parentDiscriminator = result.getPolymorphicDiscriminator()
.newBuilder()
.defaultValue(result.getPolymorphicDiscriminator()
.getClientType()
.defaultValueExpression(derivedType.getSerializedName()))
.constant(true)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixed checkstyle(field should be final).

.build();

passPolymorphicDiscriminatorToChildren(parentDiscriminator, derivedType);
Expand All @@ -383,13 +385,47 @@ public ClientModel map(ObjectSchema compositeType) {

private static void passPolymorphicDiscriminatorToChildren(ClientModelProperty parentDiscriminator,
ClientModel child) {
// Due to the execution order of ModelMapper, where children models complete mapping before the parent model,
// the parent polymorphic discriminator needs to be added at index 0. Reason, given an example where there are
// three models, where model #1 is the root parent with discriminator type, model #2 is a child of model #2 with
// discriminator kind, and model #3 is a child of model #3 with discriminator form. The order if this running
// will have model #2 add its discriminator to model #3 before model #1 runs adding its discriminator to #2 and
// #3. We want #3 to have the ordering of [type, kind], to represent the ordering of the parent models.
child.getParentPolymorphicDiscriminators().add(0, parentDiscriminator);
// A child that introduces a different discriminator still needs the fixed discriminator value selected by the
// parent hierarchy. For example, a parent may discriminate on "type", while a child fixes type="message" and
// discriminates its children on "role". The child branch must retain type="message" while dispatching by
// "role".
//
// If child.getProperties() contains a fixed property with the same serialized name and value as
// parentDiscriminator, use the matching property to build the parent discriminator entry. Then remove the
// matching property from child.getProperties() so the generated model does not contain "type" as both a normal
// property and a parent discriminator.
ClientModelProperty discriminatorForChild = parentDiscriminator;
for (int i = 0; i < child.getProperties().size(); i++) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ListIterator<ClientModelProperty> iterator = child.getProperties().listIterator();
while (iterator.hasNext()) {

ClientModelProperty childProperty = child.getProperties().get(i);
if (!Objects.equals(parentDiscriminator.getSerializedName(), childProperty.getSerializedName())) {
continue;
}

if (!childProperty.isConstant()
|| !Objects.equals(parentDiscriminator.getWireType(), childProperty.getWireType())
|| !Objects.equals(parentDiscriminator.getClientType(), childProperty.getClientType())
|| !Objects.equals(parentDiscriminator.getDefaultValue(), childProperty.getDefaultValue())) {
Comment on lines +400 to +407

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agent raises another possibility of

For example, if the parent discriminator is  type  and the child repeats it with Java  @clientName("itemType") , regenerated code can remove  getItemType()  and expose only  getType() , creating an unintended Java source compatibility break.

seems also worth an error?

throw new IllegalStateException("Property '" + childProperty.getSerializedName() + "' on model '"
+ child.getName() + "' does not match its inherited polymorphic discriminator. Expected (type="
+ parentDiscriminator.getClientType() + ", value="
+ String.valueOf(parentDiscriminator.getDefaultValue()) + "), but found (type="
+ childProperty.getClientType() + ", value=" + String.valueOf(childProperty.getDefaultValue())
+ ").");
}

discriminatorForChild = childProperty.newBuilder()
.name(parentDiscriminator.getName())
.readOnly(true)
.required(false)
.polymorphicDiscriminator(true)
.build();
child.getProperties().remove(i);
break;
}

// Children are mapped before their parents, so insert at index 0 to preserve outer-to-inner discriminator
// order.
child.getParentPolymorphicDiscriminators().add(0, discriminatorForChild);

for (ClientModel derived : child.getDerivedModels()) {
passPolymorphicDiscriminatorToChildren(parentDiscriminator, derived);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -886,18 +886,18 @@ private void addModelConstructor(ClientModel model, ClientModelPropertiesManager
*
* we use the property in this model to initiate the superclass
*/
ClientModelProperty propertyInThisModel = model.getProperties()
.stream()
ClientModelProperty overridingProperty = Stream
.concat(model.getProperties().stream(), model.getParentPolymorphicDiscriminators().stream())
.filter(p -> Objects.equals(p.getSerializedName(), property.getSerializedName()))
.findFirst()
.orElse(null);
if (propertyInThisModel != null) {
if (propertyInThisModel.isConstant() && !property.isConstant()) {
if (overridingProperty != null) {
if (overridingProperty.isConstant() && !property.isConstant()) {
// property changed to constant in this model, use constant value to initiate super
// class
superProperties.append(propertyInThisModel.getDefaultValue());
superProperties.append(overridingProperty.getDefaultValue());
} else {
superProperties.append(propertyInThisModel.getName());
superProperties.append(overridingProperty.getName());
}
} else {
// this should not happen
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2059,8 +2059,16 @@ private void writeToXml(JavaClass classBlock) {
+ propertiesManager.getXmlNamespaceConstant(namespace) + ");"));

// Assumption for XML is polymorphic discriminators are attributes.
if (propertiesManager.getDiscriminatorProperty() != null) {
serializeXml(methodBlock, propertiesManager.getDiscriminatorProperty().getProperty(), false);
ClientModelPropertyWithMetadata discriminatorProperty
= propertiesManager.getDiscriminatorProperty();
model.getParentPolymorphicDiscriminators()
.stream()
.filter(discriminator -> discriminatorProperty == null
|| !Objects.equals(discriminator.getSerializedName(),
discriminatorProperty.getProperty().getSerializedName()))
.forEach(discriminator -> serializeXml(methodBlock, discriminator, false));
if (discriminatorProperty != null) {
serializeXml(methodBlock, discriminatorProperty.getProperty(), false);
Comment on lines 2061 to +2071

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agent said this fixed xml's nested discriminator issue. Generated code looks good to me.

}

propertiesManager.forEachSuperXmlAttribute(property -> serializeXml(methodBlock, property, true));
Expand Down Expand Up @@ -2214,7 +2222,7 @@ private void writeSuperTypeFromXml(JavaClass classBlock) {
+ propertiesManager.getXmlNamespaceConstant(discriminatorProperty.getXmlNamespace()) + ", "
+ "\"" + discriminatorProperty.getSerializedName() + "\");");
} else {
methodBlock.line("String discriminatorValue = reader.getStringAttribute(" + "\""
methodBlock.line("String discriminatorValue = reader.getStringAttribute(null, " + "\""
+ discriminatorProperty.getSerializedName() + "\");");
}

Expand All @@ -2226,12 +2234,18 @@ private void writeSuperTypeFromXml(JavaClass classBlock) {
// Add deserialization for all child types.
List<ClientModel> childTypes = getAllChildTypes(model, new ArrayList<>());
for (ClientModel childType : childTypes) {
boolean sameDiscriminator = Objects.equals(childType.getPolymorphicDiscriminatorName(),
model.getPolymorphicDiscriminatorName());
if (!sameDiscriminator && !Objects.equals(childType.getParentModelName(), model.getName())) {
continue;
}

String deserializationMethod = (isSuperTypeWithDiscriminator(childType) && sameDiscriminator)
? ".fromXmlInternal(reader, finalRootElementName)"
: ".fromXml(reader, finalRootElementName)";
ifBlock = ifOrElseIf(methodBlock, ifBlock,
"\"" + childType.getSerializedName() + "\".equals(discriminatorValue)",
ifStatement -> ifStatement
.methodReturn(childType.getName() + (isSuperTypeWithDiscriminator(childType)
? ".fromXmlInternal(reader, finalRootElementName)"
: ".fromXml(reader, finalRootElementName)")));
ifStatement -> ifStatement.methodReturn(childType.getName() + deserializationMethod));
}

if (ifBlock == null) {
Expand Down Expand Up @@ -2439,6 +2453,10 @@ private void writeFromXmlDeserialization(JavaBlock methodBlock) {
}

private void deserializeXmlAttribute(JavaBlock methodBlock, ClientModelProperty attribute, boolean fromSuper) {
if (attribute.isRequired() && attribute.isConstant() && !attribute.isPolymorphicDiscriminator()) {
return;
}

String xmlAttributeDeserialization = getSimpleXmlDeserialization(attribute.getWireType(), null,
attribute.getXmlName(), propertiesManager.getXmlNamespaceConstant(attribute.getXmlNamespace()), true);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public final class GoblinShark extends Shark {
/*
* Discriminator property for Fish.
*/
private String kind = "shark";
private final String kind = "shark";

/*
* The sharktype property.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public final class SawShark extends Shark {
/*
* Discriminator property for Fish.
*/
private String kind = "shark";
private final String kind = "shark";

/*
* The sharktype property.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public class Shark extends FishInner {
/*
* Discriminator property for Fish.
*/
private String kind = "shark";
private final String kind = "shark";

/*
* The sharktype property.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ public static ChildWithRequiredPropertyAsDiscriminator fromJson(JsonReader jsonR
// Use the discriminator value to determine which subtype should be deserialized.
if ("aValue".equals(discriminatorValue)) {
return GrandChildWithRequiredProperty.fromJson(readerToUse.reset());
} else if ("nested".equals(discriminatorValue)) {
return GrandChildWithNestedDiscriminator.fromJson(readerToUse.reset());
} else {
return fromJsonKnownDiscriminator(readerToUse.reset());
}
Expand Down
Loading
Loading