Skip to content

feat: auto-set nullable: true for Kotlin nullable types in schema properties#3256

Merged
bnasslahsen merged 2 commits intospringdoc:mainfrom
thejeff77:feat/kotlin-nullable-schema-properties
Apr 10, 2026
Merged

feat: auto-set nullable: true for Kotlin nullable types in schema properties#3256
bnasslahsen merged 2 commits intospringdoc:mainfrom
thejeff77:feat/kotlin-nullable-schema-properties

Conversation

@thejeff77
Copy link
Copy Markdown
Contributor

@thejeff77 thejeff77 commented Apr 7, 2026

Problem

Springdoc correctly uses Kotlin reflection (isMarkedNullable() via SpringDocKotlinUtils.kotlinNullability()) to detect nullable types for the required list — nullable fields are excluded from required in SchemaUtils.fieldRequired(). However, it does not mark the property schema itself as nullable.

This causes OpenAPI client generators (e.g., fabrikt, openapi-generator) to produce non-null types with null defaults when generating Kotlin clients, which fails compilation:

// Generated by fabrikt from a springdoc-produced spec:
data class MyModel(
    val optionalField: String = null  // ERROR: Null cannot be a value of a non-null type 'String'
)

Solution

Adds KotlinNullablePropertyCustomizer — a ModelConverter that inspects Kotlin data class properties via kotlin-reflect and marks nullable properties in the schema.

Handles both OAS versions:

OAS 3.0 (nullable: true)

Simple types:

{ "type": "string", "nullable": true }

$ref types use allOf wrapper (since $ref and nullable are mutually exclusive siblings in OAS 3.0):

{ "nullable": true, "allOf": [{ "$ref": "#/components/schemas/NestedObject" }] }

OAS 3.1 (type arrays)

Simple types:

{ "type": ["string", "null"] }

$ref types use oneOf:

{ "oneOf": [{ "$ref": "#/components/schemas/NestedObject" }, { "type": "null" }] }

The ModelConverter detects the spec version from schema.specVersion and applies the correct strategy.

Changes

File Change
KotlinNullablePropertyCustomizer.kt New — marks nullable Kotlin properties in schema, handles both OAS 3.0 and 3.1
SpringDocKotlinConfiguration.kt Registers KotlinNullablePropertyCustomizer bean
v30/app18/ OAS 3.0 test — controller with nullable fields, expected snapshot with nullable: true and allOf wrapping
v31/app23/ OAS 3.1 test — same controller, expected snapshot with type arrays and oneOf wrapping

Auto-registered in SpringDocKotlinConfiguration.KotlinReflectDependingConfiguration when kotlin-reflect is on the classpath, following the same pattern as the existing KotlinDeprecatedPropertyCustomizer.

Test

Tests verify that a controller returning a data class with nullable fields produces a spec where:

  • Non-nullable fields (requiredField: String) are in the required list and NOT marked nullable
  • Nullable primitive fields (nullableString: String?, nullableInt: Int?) are marked nullable
  • Nullable $ref fields (nullableNested: NestedObject?) are wrapped appropriately for each OAS version

Fixes #906

…perties

Springdoc correctly uses Kotlin reflection to detect nullable types
(isMarkedNullable) for the required list — nullable fields are excluded
from `required`. However, it does not set `nullable: true` on the
property schema itself. This causes OpenAPI client generators (e.g.,
fabrikt, openapi-generator) to produce non-null types with null defaults,
which fails Kotlin compilation.

Adds KotlinNullablePropertyCustomizer that inspects Kotlin data class
properties via kotlin-reflect and sets nullable: true on schema
properties whose return type is marked nullable. Auto-registered in
SpringDocKotlinConfiguration when kotlin-reflect is on the classpath.

Fixes: springdoc#906
@Mattias-Sehlstedt
Copy link
Copy Markdown
Contributor

Hi, are you sure that the client generator supports the OAS 3.0 structure

"nullableNested": {
  "nullable": true,
  "$ref": "#/components/schemas/NestedObject"
}

?

Siblings are not supported in OAS 3.0, like how in 3.1, and more often than not any definitions next to $ref is dropped. The preferred way to define siblings in 3.0 is as far as I am aware with an allOf

"nullableNested": {
  "nullable": true,
  "allOf": [
    { "$ref": "#/components/schemas/NestedObject" }
  ]
}

On the subject of OAS versions. Could you also add a test that shows the behavior for 3.1? Since nullability is expressed completely different in that specification.

@thejeff77
Copy link
Copy Markdown
Contributor Author

@bnasslahsen Hi! This PR adds automatic nullable: true support for Kotlin nullable types (Type?) on schema properties — the feature requested in #906.

The implementation follows the exact same pattern as the existing KotlinDeprecatedPropertyCustomizer (a ModelConverter registered in SpringDocKotlinConfiguration when kotlin-reflect is on the classpath).

Could you approve the CI workflow run and take a look when you get a chance? The test snapshot may need adjustment once CI runs — happy to iterate.

Addresses reviewer feedback from @Mattias-Sehlstedt:

1. $ref properties now use allOf wrapper in OAS 3.0 instead of
   sibling nullable + $ref (which is not supported per spec):
   `{ nullable: true, allOf: [{ $ref: "..." }] }`

2. OAS 3.1 nullable support added using type arrays for simple types
   (`type: ["string", "null"]`) and oneOf for $ref types
   (`oneOf: [{ $ref: "..." }, { type: "null" }]`).

3. Added v31 test (app23) with expected snapshot showing OAS 3.1
   nullable semantics alongside the existing v30 test (app18).

The ModelConverter detects the spec version from the resolved schema
and applies the appropriate nullable strategy.
@thejeff77
Copy link
Copy Markdown
Contributor Author

@Mattias-Sehlstedt Great catches — you were right on both counts. I pushed a fix addressing both:

1. $ref + nullable siblings → allOf wrapper (OAS 3.0)

"nullableNested": {
  "nullable": true,
  "allOf": [{ "$ref": "#/components/schemas/NestedObject" }]
}

Updated the v30 test snapshot (app18) accordingly.

2. OAS 3.1 support added

For simple types: "type": ["string", "null"]
For $ref types: "oneOf": [{ "$ref": "..." }, { "type": "null" }]

The ModelConverter detects the spec version via schema.specVersion and applies the appropriate strategy. Added a v31 test (app23) with the expected 3.1 nullable semantics.

We verified all of this by running our service locally and inspecting the actual generated spec — discovered the OAS 3.1 issue the hard way when schema.setNullable(true) was silently dropped during 3.1 serialization.

@thejeff77
Copy link
Copy Markdown
Contributor Author

Additional finding from local testing: SchemaUtils.fieldRequired() (which uses kotlinNullability()) only runs for query parameters via MethodParameterPojoExtractor. For response model schemas, the required list is populated by swagger-core's ModelResolver via Jackson's KotlinAnnotationIntrospector. This means the nullable detection in SpringDocKotlinUtils is not used for response model schemas at all — which is why response models were missing both nullable: true AND the required list.

This PR fixes the nullable side. The required list for response models may need a separate fix if swagger-core's KotlinAnnotationIntrospector doesn't handle it reliably (we saw empty required lists locally for response schemas like SourceStatuses where all fields are non-nullable).

@bnasslahsen bnasslahsen merged commit 6462e32 into springdoc:main Apr 10, 2026
bnasslahsen added a commit that referenced this pull request Apr 10, 2026
…roperties

feat: auto-set nullable: true for Kotlin nullable types in schema properties
@Majstr
Copy link
Copy Markdown

Majstr commented Apr 18, 2026

This completely broke my frontend in typescript. Now open api generator created value?: string | null in stead of before value?:string. How do I disable this globally?

@0xabadea
Copy link
Copy Markdown

@Majstr #3269, if merged, will allow you to disable it.

@fatso83
Copy link
Copy Markdown

fatso83 commented Apr 22, 2026

There was some breaking change in 3.0.3 for Typescript consumers, as instead of field?: T, we now get field?: T|null.

Is this the reason why the upgrade got us these changes?
EDIT: sorry, I cannot read properly, it seems 🙈 To answer myself: yes, it is.

Now

{
  "type": [
    "integer",
    "null"
  ],
  "format": "int64"
}

Vs before:

{
  "type": "integer",
  "format": "int64"
}

EDIT: removed the question after relearning how to read.

@rupert-jung-mw
Copy link
Copy Markdown

Ok, now my typescript frontend ist also broken. @0xabadea How can I disable this behavior...?

@0xabadea
Copy link
Copy Markdown

You can remove KotlinNullablePropertyCustomizer from ModelConverters once the application has started up, but to me this approach is a hack and I wouldn't want to depend on it long-term.

@rupert-jung-mw
Copy link
Copy Markdown

rupert-jung-mw commented Apr 23, 2026

@0xabadea
Sorry, but why is a PATCH release (x.x.3) changing such a critical behavior? And why isn't there at least a config key for this to restore the original behavior? This will break so many projects, we already found two only in this commit thread...?

@thejeff77
Copy link
Copy Markdown
Contributor Author

As far as typescript, this is only loosely related. This fixes a bug where nullable kotlin types are incorrectly mapped to non-nullable Openapi types.

This will potentially adversely impact typescript generators or other generators based on the generated spec from kotlin API definitions - in that what it generates will now correctly become optional or nullable.

The previous openapi spec was incorrect.

Previously clients would have crashed if a null was correctly returned from the kotlin API and the clients were hard-coded to handle non-nullable types. Now clients that depend on the new artifact would need to update some minimal code to correctly handle the nullable type.

Is it better to have a potential crash? Or is it better to fix a minor compilation error on the newly generated client? This depends on perspective and use case.. generally, a crash could be considered worse.

The minor null handling tweaks on client libraries is acceptable in my opinion.. The spec is now correctly generated, which will impact client generation by requiring some small changes, but that is a downstream side-effect, not a breakage in the springdoc library itself.

Regarding the customizer, I can't confirm if this is needed after startup or not, but I'd be open to other ways of doing this!

@0xabadea
Copy link
Copy Markdown

Hi @thejeff77, I think there is some more nuance there. I would encourage you to read my rationale in #3269.

The previous spec was not necessarily incorrect. If the Kotlin API documents to never return nulls, just undefined-s, and it never returns nulls, then the spec is correct and there is no crash. I do understand that your original use case was different.

Regarding whether it's safe to remove the customizer from ModelConverters: I did and it didn't seem to break anything. Something like the following once the Spring Boot context is ready:

    ModelConverters.getInstance().apply {
        removeConverter(converters.first { it is KotlinNullablePropertyCustomizer })
    }

@thejeff77
Copy link
Copy Markdown
Contributor Author

@0xabadea — I went back through this with fresh eyes and pulled the swagger-core history. You were right on the substance, and I want to lay out where I've landed.

On the concern itself — it's valid and I underweighted it earlier. This isn't really a TypeScript-generator complaint. The actual problem is that my PR introduced auto-detection of nullable from Kotlin T? without any per-property mechanism for users to override it. For a backend using @JsonInclude(NON_NULL) (a widespread Jackson convention), the resulting spec is wrong on the wire-format axis: it claims null can appear when in fact Jackson strips null fields. Marking nullable was the right answer for some Jackson configurations and the wrong one for yours — and the customizer can't tell the difference from reflection alone. I should have caught this before merging.

Why I'm pushing for reverting my PR rather than landing #3269 as the fix. I want to be direct about this because the reasoning matters:

The deeper bug isn't "auto-detection is too aggressive globally." It's that users have no way to override the auto-detection per-property via the natural @Schema(nullable = false) mechanism. They reasonably expect that annotation to win over inference, and it doesn't. Worse — it can't, because @Schema.nullable() defaults to false, indistinguishable via reflection from "not specified." Even if springdoc tried to honor an explicit nullable = false, we couldn't reliably tell user intent from default value. That's the load-bearing reason this PR shouldn't have shipped in its current form.

A global flag (#3269) addresses the symptom but leaves the per-property bug in place. A codebase that mixes Jackson configs — some DTOs @JsonInclude(NON_NULL), others default — still has no clean answer; it's all-on or all-off. And once a flag ships, it's API surface we have to keep, which makes deprecating it later (when the proper fix lands) harder. I'd rather not introduce it at all.

Reverting cleanly removes the bug. The @Schema(nullable = true) opt-in path that existed before my PR is restored. Users in the original #906 cohort fall back to the explicit-annotation workaround they had been using for years — not great, but a known and bounded inconvenience, far better than silently emitting wrong specs.

The proper fix lives upstream in swagger-core, not in springdoc. This Mode pattern is already established there, twice, deliberately:

Swagger-core has also been independently building auto-detection of nullable on the same rail: swagger-api/swagger-core#5018 (2025-11) added native @Nullable annotation support, with iterations in swagger-api/swagger-core#5077, swagger-api/swagger-core#5158, and upcoming swagger-api/swagger-core#5124. They will hit the same override gap eventually, since their auto-detection feeds the same Schema.nullable boolean. A NullableMode upstream solves both layers at once — @Nullable annotations and Kotlin T? reflection.

What I've done about it:

  1. Opened the revert: revert: auto-set nullable for Kotlin nullable types (#3256) #3276. Full historical reasoning is documented in the PR description so future contributors don't relitigate.
  2. Suggesting we close Add mechanism to disable nullable for Kotlin properties #3269 in favor of the revert. To be explicit: your work and pushback directly shaped how I got here — but shipping the flag would entrench a partial fix and make the upstream proposal harder to motivate later.
  3. Opened the swagger-core proposal: Proposal: introduce Schema.NullableMode enum to mirror RequiredMode / AccessMode pattern swagger-api/swagger-core#5160NullableMode { AUTO, NULLABLE, NOT_NULLABLE } mirroring RequiredMode.
  4. Once that lands, re-introduce the springdoc customizer with NullableMode honoring — auto-detect from Kotlin T? only when no explicit override is present.

Thanks for the patience while I caught up.

@thejeff77
Copy link
Copy Markdown
Contributor Author

Update: the upstream NullableMode proposal mentioned above is now both filed and implemented:

Once that lands and ships, springdoc can re-introduce this customizer with NullableMode.NOT_NULLABLE honored as the explicit per-property override.

@0xabadea
Copy link
Copy Markdown

@thejeff77 very much in agreement with everything that you've said. Thank you for the revert, that is indeed the better way going forward.

There's one thing I want to touch on. You mention in a number of places that the NullableMode-honoring customizer should support a per-property override, stress on "per-property". I believe that an override that only works on a per-property basis would still be cumbersome to use in codebases that rely on @JsonInclude(NON_NULL), which is, as you (correctly) agreed in #3276, a widespread convention.

In practice, we use @JsonInclude annotations sparingly; instead, we configure a global Spring Boot property, spring.jackson.default-property-inclusion. Hence, I would not want to litter the code base with @Schema(nullableMode = NOT_NULLABLE) annotations on all Kotlin nullable properties (of which we have perhaps a hundred). Instead, I would prefer that there was a global Springdoc nullable mode config -- just as Spring Boot provides a global default for Jackson.

Of course, the ability to override the global nullable mode on a per-property basis remains beneficial and perfectly in line with the ability to override the Spring-configured Jackson inclusion via @JsonInclude annotations.

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.

Support for auto conversion of Kotlin nullable type to schema: { nullable: true }

7 participants