diff --git a/typescript-generator-core/src/main/java/cz/habarta/typescript/generator/compiler/ModelCompiler.java b/typescript-generator-core/src/main/java/cz/habarta/typescript/generator/compiler/ModelCompiler.java index facaec585..628d0fdda 100644 --- a/typescript-generator-core/src/main/java/cz/habarta/typescript/generator/compiler/ModelCompiler.java +++ b/typescript-generator-core/src/main/java/cz/habarta/typescript/generator/compiler/ModelCompiler.java @@ -65,6 +65,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.Comparator; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -178,6 +179,9 @@ public TsModel javaToTypeScript(Model model) { // after enum transformations transform Maps with rest of the enums (not unions) used in keys tsModel = transformNonStringEnumKeyMaps(symbolTable, tsModel); + // exclude sealed marker types (sealed classes/interfaces without @JsonSubTypes) + tsModel = excludeSealedMarkerTypes(symbolTable, tsModel); + // tagged unions tsModel = createAndUseTaggedUnions(symbolTable, tsModel); @@ -1042,6 +1046,159 @@ private static TsEnumModel addEnumValuesToJavadoc(TsEnumModel enumModel) { } } + /** + * Excludes sealed marker types from the model. + * A sealed marker is a sealed class/interface without @JsonSubTypes annotation. + * These are intermediate grouping types that should not appear in the output. + * Their permitted subclasses are expanded into parent unions instead. + */ + private TsModel excludeSealedMarkerTypes(final SymbolTable symbolTable, TsModel tsModel) { + // Identify sealed marker types: sealed AND no @JsonSubTypes + final Set> sealedMarkers = tsModel.getBeans().stream() + .map(TsBeanModel::getOrigin) + .filter(cls -> cls != null && isSealedMarker(cls)) + .collect(Collectors.toSet()); + + if (sealedMarkers.isEmpty()) { + return tsModel; + } + + // Build map from sealed marker to its parent (for replacing parent refs) + final Map, Class> sealedMarkerToParent = new HashMap<>(); + for (Class marker : sealedMarkers) { + // Find the marker's parent that is NOT a sealed marker + Class parent = findNonSealedParent(marker, sealedMarkers); + if (parent != null) { + sealedMarkerToParent.put(marker, parent); + } + } + + // Remove sealed markers from beans list and update parent references + final List updatedBeans = new ArrayList<>(); + for (TsBeanModel bean : tsModel.getBeans()) { + if (sealedMarkers.contains(bean.getOrigin())) { + // Skip sealed markers - don't generate them + continue; + } + + TsBeanModel updatedBean = bean; + + // Update parent if it points to a sealed marker + if (bean.getParent() != null) { + final Class parentClass = getOriginClass(symbolTable, bean.getParent()); + if (parentClass != null && sealedMarkers.contains(parentClass)) { + final Class newParent = sealedMarkerToParent.get(parentClass); + if (newParent != null) { + updatedBean = updatedBean.withParent(typeFromJava(symbolTable, newParent)); + } + } + } + + // Update extendsList to replace sealed markers with their parents + if (!bean.getExtendsList().isEmpty()) { + final List newExtends = new ArrayList<>(); + for (TsType ext : bean.getExtendsList()) { + final Class extClass = getOriginClass(symbolTable, ext); + if (extClass != null && sealedMarkers.contains(extClass)) { + final Class newExt = sealedMarkerToParent.get(extClass); + if (newExt != null) { + newExtends.add(typeFromJava(symbolTable, newExt)); + } + } else { + newExtends.add(ext); + } + } + if (!newExtends.equals(bean.getExtendsList())) { + updatedBean = updatedBean.withExtends(newExtends); + } + } + + // Update taggedUnionClasses to expand sealed markers + if (!bean.getTaggedUnionClasses().isEmpty()) { + final List> expandedClasses = new ArrayList<>(); + for (Class cls : bean.getTaggedUnionClasses()) { + if (sealedMarkers.contains(cls)) { + expandedClasses.addAll(getSealedPermittedSubclasses(cls, sealedMarkers)); + } else { + expandedClasses.add(cls); + } + } + if (!expandedClasses.equals(bean.getTaggedUnionClasses())) { + updatedBean = updatedBean.withTaggedUnion(expandedClasses, bean.getDiscriminantProperty(), bean.getDiscriminantLiteral()); + } + } + + updatedBeans.add(updatedBean); + } + + return tsModel.withBeans(updatedBeans); + } + + /** + * Finds the first non-sealed parent of a class. + */ + private static Class findNonSealedParent(Class cls, Set> sealedMarkers) { + // Check superclass first + Class superClass = cls.getSuperclass(); + if (superClass != null && superClass != Object.class) { + if (!sealedMarkers.contains(superClass)) { + return superClass; + } else { + return findNonSealedParent(superClass, sealedMarkers); + } + } + // Check interfaces + for (Class iface : cls.getInterfaces()) { + if (!sealedMarkers.contains(iface)) { + return iface; + } else { + Class parent = findNonSealedParent(iface, sealedMarkers); + if (parent != null) { + return parent; + } + } + } + return null; + } + + /** + * Checks if a class is a sealed marker (sealed without @JsonSubTypes and not a tagged union parent). + * A sealed marker is an intermediate sealed type that just groups other types but doesn't define + * its own discriminant or subtypes via Jackson annotations. + */ + private static boolean isSealedMarker(Class cls) { + if (!cls.isSealed()) { + return false; + } + // Check if it has @JsonSubTypes - if it does, it's not just a marker + final com.fasterxml.jackson.annotation.JsonSubTypes subTypesAnn = cls.getAnnotation(com.fasterxml.jackson.annotation.JsonSubTypes.class); + if (subTypesAnn != null && subTypesAnn.value().length > 0) { + return false; + } + // Check if it's a tagged union parent (has @JsonTypeInfo with NAME) - if so, it's not just a marker + final com.fasterxml.jackson.annotation.JsonTypeInfo typeInfoAnn = cls.getAnnotation(com.fasterxml.jackson.annotation.JsonTypeInfo.class); + if (typeInfoAnn != null && typeInfoAnn.use() == com.fasterxml.jackson.annotation.JsonTypeInfo.Id.NAME) { + return false; + } + return true; + } + + /** + * Gets all permitted subclasses of a sealed type, recursively expanding nested sealed markers. + */ + private static List> getSealedPermittedSubclasses(Class sealedClass, Set> sealedMarkers) { + final List> result = new ArrayList<>(); + for (Class permitted : sealedClass.getPermittedSubclasses()) { + if (sealedMarkers.contains(permitted)) { + // Recursively expand nested sealed markers + result.addAll(getSealedPermittedSubclasses(permitted, sealedMarkers)); + } else { + result.add(permitted); + } + } + return result; + } + private TsModel createAndUseTaggedUnions(final SymbolTable symbolTable, TsModel tsModel) { if (settings.disableTaggedUnions) { return tsModel; diff --git a/typescript-generator-core/src/main/java/cz/habarta/typescript/generator/emitter/TsBeanModel.java b/typescript-generator-core/src/main/java/cz/habarta/typescript/generator/emitter/TsBeanModel.java index db1799abf..4d20af27f 100644 --- a/typescript-generator-core/src/main/java/cz/habarta/typescript/generator/emitter/TsBeanModel.java +++ b/typescript-generator-core/src/main/java/cz/habarta/typescript/generator/emitter/TsBeanModel.java @@ -183,4 +183,9 @@ public TsBeanModel withExtends(List extendsList) { taggedUnionClasses, discriminantProperty, discriminantLiteral, taggedUnionAlias, properties, constructor, methods, comments); } + public TsBeanModel withParent(TsType parent) { + return new TsBeanModel(origin, category, isClass, decorators, name, typeParameters, parent, extendsList, implementsList, + taggedUnionClasses, discriminantProperty, discriminantLiteral, taggedUnionAlias, properties, constructor, methods, comments); + } + } diff --git a/typescript-generator-core/src/test/java/cz/habarta/typescript/generator/SealedInterfaceTest.java b/typescript-generator-core/src/test/java/cz/habarta/typescript/generator/SealedInterfaceTest.java new file mode 100644 index 000000000..b08098f35 --- /dev/null +++ b/typescript-generator-core/src/test/java/cz/habarta/typescript/generator/SealedInterfaceTest.java @@ -0,0 +1,65 @@ + +package cz.habarta.typescript.generator; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeName; +import java.math.BigDecimal; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + + +@SuppressWarnings("unused") +public class SealedInterfaceTest { + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "unit") + public sealed interface Quantity { + sealed interface DecimalAmount extends Quantity { + @JsonTypeName("gram") + record Gram(BigDecimal amount) implements DecimalAmount {} + + @JsonTypeName("kilogram") + record Kilogram(BigDecimal amount) implements DecimalAmount {} + + @JsonTypeName("liter") + record Liter(BigDecimal amount) implements DecimalAmount {} + + @JsonTypeName("milliliter") + record Milliliter(BigDecimal amount) implements DecimalAmount {} + + @JsonTypeName("arbitrary") + record Arbitrary(BigDecimal amount) implements DecimalAmount {} + } + + @JsonTypeName("unspecified") + record Unspecified(String notes) implements Quantity {} + } + + static class Recipe { + public List quantities; + } + + @Test + public void testSealedInterfaceMarkerExcluded() { + final Settings settings = TestUtils.settings(); + settings.quotes = "'"; + final String output = new TypeScriptGenerator(settings).generateTypeScript(Input.from(Recipe.class)); + + // Should NOT contain DecimalAmount interface - it's a sealed marker without @JsonSubTypes + Assertions.assertFalse(output.contains("interface DecimalAmount"), + "DecimalAmount should not appear as it's a sealed marker without @JsonSubTypes\n" + output); + + // Should NOT contain DecimalAmount in the union + Assertions.assertFalse(output.contains("| DecimalAmount"), + "DecimalAmount should not appear in QuantityUnion\n" + output); + + // Should have leaf types + Assertions.assertTrue(output.contains("interface Gram"), "Should have Gram"); + Assertions.assertTrue(output.contains("interface Kilogram"), "Should have Kilogram"); + Assertions.assertTrue(output.contains("interface Unspecified"), "Should have Unspecified"); + + // Union should only have leaf types + Assertions.assertTrue(output.contains("type QuantityUnion = Gram | Kilogram | Liter | Milliliter | Arbitrary | Unspecified"), + "QuantityUnion should only contain leaf types\n" + output); + } +}