OpenAPITools / OpenAPITools/openapi-generator
BUG][csharp][generichost] Nullable value-type properties dropped to non-nullable when inherited via `allOf`, causing JsonException on null
Nobody has claimed this yet.
- Dominant language
- Java
- Stars
- 26.8k
- Forks
- 7.7k
- PR merge metrics
- PR metrics pending
Description
Bug Report Checklist
- Have you provided a full/minimal spec to reproduce the issue?
- Have you validated the input using an OpenAPI validator?
- Have you tested with the latest master to confirm the issue still exists?
- Have you searched for related issues/PRs? (See "Related issues" below.)
- What's the actual output vs expected output?
- [Optional] Sponsorship to speed up the bug fix or feature request
Description
When a schema uses allOf to inherit a base that declares a nullable value-type property (type: ['string', 'null'] with format: date-time, etc.), the generated JsonConverter for the derived class deserializes the property as non-nullable T (e.g. JsonSerializer.Deserialize<DateTimeOffset>) and emits an ArgumentNullException throw-on-null check. The base class generates the same property correctly (Deserialize<DateTimeOffset?>, no throw). At runtime, any payload with JSON null for that property fails:
System.Text.Json.JsonException: The JSON value could not be converted to System.DateTimeOffset.
---> System.InvalidOperationException: Cannot get the value of a token type 'Null' as a string.
at System.Text.Json.Utf8JsonReader.GetDateTimeOffset()
at <DerivedDto>JsonConverter.Read(...)
The wrapper type Option<T?> is generated correctly in the derived class — only the inner Deserialize<T> call type and the post-deserialization null check are wrong. The asymmetry shows that vendorExtensions.x-is-value-type survives the allOf flattening (so the wrapper gets T?), while isNullable does not (so the deserialize call gets T and the throw block fires). This is the same root mismatch identified in #23530, but here it produces a runtime crash, not just generated-code inconsistency.
openapi-generator version
Reproduced on latest master (7.x line). Did not occur on the legacy restsharp library — first observable after switching the C# library to generichost.
OpenAPI declaration
openapi: 3.1.0
info: { title: repro, version: 1.0.0 }
paths: {}
components:
schemas:
Base:
type: object
properties:
requiredAt:
type: string
format: date-time
optionalAt:
type: ['string', 'null']
format: date-time
required: [requiredAt]
Derived:
allOf:
- $ref: '#/components/schemas/Base'
- type: object
properties:
extra:
type: string
Generation Details
openapi-generator-cli generate -i spec.yaml -g csharp \
--additional-properties=library=generichost,useDateTimeOffset=true,targetFramework=net10.0
Steps to reproduce
- Generate from the spec above.
- Diff
Model/BaseJsonConverter.ReadagainstModel/DerivedJsonConverter.Readfor theoptionalAtcase. The base emitsDeserialize<DateTimeOffset?>and no throw block; the derived emitsDeserialize<DateTimeOffset>and anArgumentNullExceptionthrow. - Deserialize
{"requiredAt":"2026-04-14T16:00:44Z","optionalAt":null,"extra":"x"}intoDerived— the call throws.
Expected vs actual
| Base (correct) | Derived via allOf (buggy) |
|
|---|---|---|
| Wrapper | Option<DateTimeOffset?> |
Option<DateTimeOffset?> |
| Deserialize call | Deserialize<DateTimeOffset?> |
Deserialize<DateTimeOffset> |
| Throw-on-null block | absent | present |
Expected: derived matches base.
Related issues / PRs
- #23530 — same template-signal mismatch (
OptionProperty.mustacheusesx-is-value-type,JsonConverter.mustacheusesisNullable), identified without theallOfangle. Most directly related. - #22474 — same throw-on-null block fires for non-required nullable fields.
- #23190 (PR #23189) — same throw block, reference-type non-required case.
Suggested fix
Two layers:
-
Java side (root cause). In the
allOf-flattening logic ofDefaultCodegen/CSharpClientCodegen, when a base schema's properties are merged onto a derived schema,CodegenProperty.isNullableis overwritten tofalsefor value-type properties even when the base spec marks them nullable. The fix is to preserveisNullablefor inherited properties. -
Mustache side (workaround that also makes the templates self-consistent). In
csharp/libraries/generichost/JsonConverter.mustache:
- Lines 220–225 (
isDate/isDateTimedeserialize): always emit the?for value types — the wrapper is alreadyOption<T?>and reading asT?from non-null JSON is a no-op for value types. This matches the contract thatOptionProperty.mustachealready enforces. - Lines 277–283 (throw-on-null block): emit the throw for (reference type) OR (value type AND required); skip only for non-required value types. Concretely:
Rationale:{{#allVars}} {{^isNullable}} {{#vendorExtensions.x-is-reference-type}} if ({{name}}.IsSet && {{name}}.Value == null) throw new ArgumentNullException(...); {{/vendorExtensions.x-is-reference-type}} {{#vendorExtensions.x-is-value-type}} {{#required}} if ({{name}}.IsSet && {{name}}.Value == null) throw new ArgumentNullException(...); {{/required}} {{/vendorExtensions.x-is-value-type}} {{/isNullable}} {{/allVars}}requiredsurvivesallOfflattening, so it's a reliable proxy for "callers genuinely expect a value here." Required value types keep the clearArgumentNullException; the only surface that loses strictness is non-required non-nullable value types, where the template can't tell apart "spec-nullable but reset by allOf" from "genuinely non-nullable optional." That's a narrow tolerance shift, not a crash.
PR welcome — happy to put one up.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by tracing allOf property merging in DefaultCodegen and CSharpClientCodegen, then inspect csharp/libraries/generichost/JsonConverter.mustache and the related OptionProperty.mustache behavior. Run the supplied generation command with the minimal spec and compare BaseJsonConverter.Read with DerivedJsonConverter.Read; done means the derived converter preserves nullable deserialization and avoids the erroneous throw for optional nullable values.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp, java
- Domain
- backend-api-design, tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100