Skip to content

[kotlin-spring] Add sealed class polymorphism support#23400

Open
rvlasveld wants to merge 2 commits intoOpenAPITools:masterfrom
rvlasveld:feature/kotlin-spring-polymorphism
Open

[kotlin-spring] Add sealed class polymorphism support#23400
rvlasveld wants to merge 2 commits intoOpenAPITools:masterfrom
rvlasveld:feature/kotlin-spring-polymorphism

Conversation

@rvlasveld
Copy link
Copy Markdown

@rvlasveld rvlasveld commented Mar 31, 2026

Summary

Add opt-in useSealedDiscriminatorClasses configuration option (default false) that enables proper polymorphism support for the kotlin-spring generator using sealed classes instead of interfaces.

When enabled:

  • Discriminator parent models are generated as Kotlin sealed classes with Jackson @JsonTypeInfo/@JsonSubTypes annotations
  • Child classes extend the parent via constructor inheritance with override properties
  • Both oneOf (type union) and allOf (inheritance) discriminator patterns are supported
  • A companion fixJacksonJsonTypeInfoInheritance option controls Jackson visible flag behavior

This approach mirrors the polymorphism support added to the kotlin-server generator in #22610.

Default behavior is unchanged

The flag defaults to false, preserving the existing interface-based generation. Zero sample files change. This is a non-breaking change.

Motivation

The current interface-based polymorphism in kotlin-spring has several long-standing issues:

  • Parent interfaces incorrectly contain all children's properties
  • Child classes don't properly implement the parent interface
  • Missing override keyword on inherited properties
  • Multi-level inheritance produces invalid data class hierarchies that don't compile
  • Discriminator field serialized twice in JSON output

Related Issues

Fixes #18167, #18206, #11347, #8060
Related: #8366, #8059

Related PRs

This PR supersedes the following long-stale open PRs that address subsets of the same problem:

Usage

# In your openapi-generator config
additionalProperties:
  useSealedDiscriminatorClasses: true
  # Optional: disable Jackson fix (default true)
  # fixJacksonJsonTypeInfoInheritance: false

Example: oneOf with discriminator

// Generated parent
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "petType", visible = true)
@JsonSubTypes(
    JsonSubTypes.Type(value = Cat::class, name = "cat"),
    JsonSubTypes.Type(value = Dog::class, name = "dog")
)
sealed class Pet(
    open val petType: kotlin.String
)

// Generated child
data class Cat(
    override val petType: kotlin.String = "cat",
    val huntingSkill: Cat.HuntingSkill
) : Pet(petType = petType)

Test plan

  • 6 new test methods covering oneOf, allOf, no-discriminator, and config toggle scenarios
  • All 149 existing tests pass with 0 regressions
  • Samples regenerated (./bin/generate-samples.sh ./bin/configs/kotlin-spring*) — 0 sample changes
  • Docs exported (./bin/utils/export_docs_generators.sh)

🤖 Generated with Claude Code

cc @jimschubert @dr4ke616 @karismann @Zomzog @andrewemery @4brunu @yutaka0m @stefankoppier


Summary by cubic

Adds opt‑in sealed class polymorphism to the kotlin-spring generator with Jackson-backed discriminators; supports both oneOf and allOf. Default behavior stays the same.

  • New Features

    • useSealedDiscriminatorClasses (default false) generates discriminator parents as Kotlin sealed classes and children with override properties that call the parent constructor.
    • fixJacksonJsonTypeInfoInheritance (default true) sets @JsonTypeInfo.visible when needed and adds the discriminator property to children with sensible defaults.
    • oneOf: parent sealed class contains only the discriminator; children override it with a default and pass it to super. allOf: parent sealed class declares its properties; children override inherited properties and pass them via the super constructor.
    • Updates: new dataClassSealedVar.mustache; dataClass.mustache now always emits : Parent() when sealed parents have no ctor args; typeInfoAnnotation.mustache toggles visible based on context; added postProcessAllModels() to build parent/child mappings, mark inherited props, set ctor args, and fix hasVars/hasRequired for discriminator-only children. Docs updated (options + oneOf/allOf support), with tests covering oneOf, allOf, no-discriminator, and the visibility toggle.
  • Migration

    • Enable via additionalProperties.useSealedDiscriminatorClasses=true.
    • Optional: set additionalProperties.fixJacksonJsonTypeInfoInheritance=false to keep visible=false unless all children define the discriminator.

Written for commit cb7a964. Summary will update on new commits.

…scriminatorClasses option

Add opt-in `useSealedDiscriminatorClasses` configuration option (default `false`) that enables proper polymorphism support for the kotlin-spring generator using sealed classes instead of interfaces.

When enabled, discriminator parent models are generated as Kotlin sealed classes with proper Jackson `@JsonTypeInfo`/`@JsonSubTypes` annotations. Child classes extend the parent via constructor inheritance with `override` properties. This approach mirrors the polymorphism support added to the kotlin-server generator in OpenAPITools#22610.

The motivation for this change is that the current interface-based polymorphism has several long-standing issues: parent interfaces incorrectly contain all children's properties, child classes don't properly implement the parent interface, the `override` keyword is missing on inherited properties, and multi-level inheritance produces invalid data class hierarchies that don't compile. These issues have been tracked across multiple GitHub issues over several years.

The implementation adds a `postProcessAllModels()` method (ported from `KotlinServerCodegen`) that builds parent-child relationships for discriminator models in three passes: first collecting discriminator mappings, then processing child models to mark inherited properties and generate parent constructor arguments, and finally configuring vendor extensions that control the template rendering.

Both oneOf (type union) and allOf (inheritance) discriminator patterns are supported. For oneOf, the parent sealed class contains only the discriminator property; children get the discriminator as an overridden property with a default value. For allOf, the parent sealed class contains all its declared properties; children override inherited properties and pass them via the parent constructor call.

A companion `fixJacksonJsonTypeInfoInheritance` option (default `true`, only applies when sealed classes are enabled) controls whether Jackson's `visible` flag is set to `true` on `@JsonTypeInfo` and whether discriminator properties are automatically added to child models with appropriate default values.

The default behavior (`useSealedDiscriminatorClasses=false`) is completely unchanged — existing users continue to get interface-based generation with zero sample file changes. This makes the change non-breaking and suitable for a minor release.

New template `dataClassSealedVar.mustache` renders sealed class constructor properties with `open`/`override` modifiers and `@get:Schema`/`@get:JsonProperty` annotations. The existing `dataClass.mustache` and `typeInfoAnnotation.mustache` templates are updated to conditionally render either interface or sealed class based on the flag.

Six new test methods cover oneOf with discriminator, allOf with discriminator, polymorphism without discriminator, and the `fixJacksonJsonTypeInfoInheritance` toggle. All 149 existing tests continue to pass.

Fixes OpenAPITools#18167, OpenAPITools#18206, OpenAPITools#11347, OpenAPITools#8060
Related: OpenAPITools#8366, OpenAPITools#8059

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@rvlasveld
Copy link
Copy Markdown
Author

@cubic-dev-ai review this PR

@cubic-dev-ai
Copy link
Copy Markdown
Contributor

cubic-dev-ai bot commented Mar 31, 2026

@cubic-dev-ai review this PR

@rvlasveld I have started the AI code review. It will take a few minutes to complete.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

No issues found across 6 files

@rvlasveld rvlasveld marked this pull request as ready for review March 31, 2026 14:21
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

2 issues found across 6 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache">

<violation number="1" location="modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache:30">
P1: Generated Kotlin child class can fail to compile by extending a sealed/class parent without invoking the parent constructor (`: Parent` instead of `: Parent()`) when `x-parent-ctor-args` is absent.</violation>
</file>

<file name="modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java">

<violation number="1" location="modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java:1392">
P1: Model boolean state (`hasVars`/`hasRequired`) is not updated after adding discriminator properties, leaving stale flags and potentially incorrect generated Kotlin model output.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Address two issues identified by cubic code review:

1. Always emit parentheses in parent constructor call when
   `useSealedDiscriminatorClasses` is enabled, even when
   `x-parent-ctor-args` is absent. This prevents invalid Kotlin like
   `: Parent` when the parent is a sealed class (should be `: Parent()`).

2. Update `hasVars` and `hasRequired` flags on child models after adding
   discriminator properties in the oneOf pattern. Without this, a child
   with no other properties would generate `class Cat(...)` instead of
   `data class Cat(...)`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@rvlasveld
Copy link
Copy Markdown
Author

Note: this reply is written by Claude (AI), not the PR author directly.

Both issues identified by cubic are valid and have been fixed in cb7a964:

Issue 1 (dataClass.mustache:30): The template now always emits () when useSealedDiscriminatorClasses is enabled, even when x-parent-ctor-args is absent. This matches the kotlin-server template approach. When the flag is off (default), the legacy behavior is preserved (no parentheses for interface parents).

Issue 2 (KotlinSpringServerCodegen.java:1392): Added childModel.setHasVars(true) and childModel.setHasRequired(true) after adding discriminator properties to child models. This ensures the data keyword is correctly generated.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache">

<violation number="1" location="modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache:30">
P2: Parent constructor arguments are now emitted only when useSealedDiscriminatorClasses is true, which can break non-sealed inheritance cases where the parent has required constructor parameters and x-parent-ctor-args is set.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

{{/-last}}{{/optionalVars}}
){{/discriminator}}{{! no newline
}}{{#parent}} : {{{.}}}{{#isMap}}(){{/isMap}}{{! no newline
}}{{#parent}} : {{{.}}}{{#isMap}}(){{/isMap}}{{^isMap}}{{#useSealedDiscriminatorClasses}}({{#vendorExtensions.x-parent-ctor-args}}{{{vendorExtensions.x-parent-ctor-args}}}{{/vendorExtensions.x-parent-ctor-args}}){{/useSealedDiscriminatorClasses}}{{/isMap}}{{! no newline
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 31, 2026

Choose a reason for hiding this comment

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

P2: Parent constructor arguments are now emitted only when useSealedDiscriminatorClasses is true, which can break non-sealed inheritance cases where the parent has required constructor parameters and x-parent-ctor-args is set.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache, line 30:

<comment>Parent constructor arguments are now emitted only when useSealedDiscriminatorClasses is true, which can break non-sealed inheritance cases where the parent has required constructor parameters and x-parent-ctor-args is set.</comment>

<file context>
@@ -27,7 +27,7 @@
 {{/-last}}{{/optionalVars}}
 ){{/discriminator}}{{! no newline
-}}{{#parent}} : {{{.}}}{{#isMap}}(){{/isMap}}{{^isMap}}{{#vendorExtensions.x-parent-ctor-args}}({{{vendorExtensions.x-parent-ctor-args}}}){{/vendorExtensions.x-parent-ctor-args}}{{/isMap}}{{! no newline
+}}{{#parent}} : {{{.}}}{{#isMap}}(){{/isMap}}{{^isMap}}{{#useSealedDiscriminatorClasses}}({{#vendorExtensions.x-parent-ctor-args}}{{{vendorExtensions.x-parent-ctor-args}}}{{/vendorExtensions.x-parent-ctor-args}}){{/useSealedDiscriminatorClasses}}{{/isMap}}{{! no newline
       }}{{#vendorExtensions.x-kotlin-implements}}, {{{.}}}{{/vendorExtensions.x-kotlin-implements}}{{! <- serializableModel is also handled via x-kotlin-implements
       }}{{#vendorExtensions.x-implements-sealed-interfaces}}{{#.}}, {{{.}}}{{/.}}{{/vendorExtensions.x-implements-sealed-interfaces}}{{! <- add sealed interface implementations
</file context>
Fix with Cubic

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Note: this reply is written by Claude (AI), not the PR author directly.

False positive. The x-parent-ctor-args vendor extension is only ever set inside postProcessAllModels(), which returns early when useSealedDiscriminatorClasses is false (line 1168). No other code path sets this extension. So the template guard ({{#useSealedDiscriminatorClasses}}) is logically consistent — both the producer and consumer of x-parent-ctor-args are gated on the same flag.

@rvlasveld
Copy link
Copy Markdown
Author

Note: this reply is written by Claude (AI), not the PR author directly.

Re: P2 (dataClass.mustache:30) — This is a false positive. The x-parent-ctor-args vendor extension is only ever set inside postProcessAllModels(), which returns early when useSealedDiscriminatorClasses is false (line 1168: if (!useSealedDiscriminatorClasses) return objs;). No other code path in KotlinSpringServerCodegen or AbstractKotlinCodegen sets this extension. So the guard in the template ({{#useSealedDiscriminatorClasses}}) is logically consistent — both the producer and consumer of x-parent-ctor-args are gated on the same flag.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG][KOTLIN] Polymorphism not working

1 participant