Summary
For a property that is both in required and nullable, the generator emits a Swift Optional with a = nil default in the initializer, and relies on synthesized Codable, whose encodeIfPresent omits the key when the value is nil.
The result is that no value of the generated type can produce a request body that conforms to the document: the property is mandatory per the schema, but the client either sends a real value or sends nothing at all. Sending an explicit null — the whole point of declaring the property nullable — is unreachable.
This is not #419, and that distinction is the point
I want to lead with this, because it looks superficially like #419.
#419 is about optional + nullable, where there genuinely are three states — absent, null, and a value — and Swift's T? only models two. That needs a design (the PotentiallyAbsent discussion, a custom Encoder/Decoder), which is presumably why it has been open since 2023.
Required + nullable has only two states. Absence is forbidden by the schema, so nil can only ever mean JSON null. T? models it exactly. There is no ambiguity to resolve and no new type required — the current output simply discards information the schema provides.
The typeOverrides workaround suggested in #419 (comment) also doesn't reach this case: it is keyed on named component schemas, and generators that emit inline request bodies have no schema name to key on.
Reproduction
openapi: 3.1.0
info: { title: repro, version: 1.0.0 }
paths:
/vote:
post:
operationId: castVote
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [id, vote] # <- `vote` IS required
properties:
id: { type: number }
vote: { type: [boolean, "null"] } # <- and nullable
responses:
"204": { description: ok }
Generated:
public var vote: Swift.Bool?
public init(
id: Swift.Double,
vote: Swift.Bool? = nil // defaulted, despite being required
) { ... }
No custom encode(to:) is generated for this struct, so synthesized Codable applies and vote: nil encodes as an absent key rather than null.
The clearest evidence
Generating from the same document twice — once with vote in required, once without — produces byte-identical Swift. The required array has no observable effect on a nullable property.
required: ["id","vote"] -> public var vote: Swift.Bool? init(… vote: Swift.Bool? = nil)
required: ["id"] -> public var vote: Swift.Bool? init(… vote: Swift.Bool? = nil)
For contrast, a required non-nullable open schema (vote: {}) does generate a non-optional property with no default, and OpenAPIValueContainer encodes nil as an explicit null via encodeNil():
public var vote: OpenAPIRuntime.OpenAPIValueContainer // non-optional, no default
So the runtime can already express "present and null" — it is only the type-assignment layer that cannot reach it, and only by way of total type erasure.
Where it comes from
Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/TypeMatcher.swift:
func isOptional(_ schema: JSONSchema, components: OpenAPI.Components) throws -> Bool {
if schema.nullable || !schema.required { return true }
...
}
required is never consulted once nullable is true, so nullable-and-required collapses into the same representation as not-required. The property then picks up a = nil default in translateStructBlueprint, and the struct keeps OpenAPICodableStrategy.synthesized.
Suggested fix
Scoped to required && nullable only, leaving #419's harder case untouched:
- Emit no
= nil default for a required property, so the caller must make an explicit choice.
- Encode with
encode rather than inheriting synthesized encodeIfPresent, so nil serializes as null.
(2) needs a custom encode(to:) when a struct has at least one required-nullable property. OpenAPICodableStrategy already carries custom-encoder cases for the additional-properties path, so this looks like an additional strategy rather than new architecture. Decoding needs no change — decodeIfPresent already maps both null and absent to nil, and absent cannot legally occur.
I'd be glad to hear whether that framing is acceptable before anyone spends time on an implementation.
Environment
- swift-openapi-generator 1.13.0 (also reproduced on 1.12.2)
- swift-openapi-runtime 1.12.0
- Swift 6.3.3, macOS arm64
Impact
Any server whose contract is "you must send this field, and null is a meaningful value" — clearing a vote, unsetting an image — is unreachable from a generated Swift client. The server rejects the request with a validation error for a missing required property, and there is no way to construct a conforming one short of hand-writing the body type.
Summary
For a property that is both in
requiredand nullable, the generator emits a SwiftOptionalwith a= nildefault in the initializer, and relies on synthesizedCodable, whoseencodeIfPresentomits the key when the value isnil.The result is that no value of the generated type can produce a request body that conforms to the document: the property is mandatory per the schema, but the client either sends a real value or sends nothing at all. Sending an explicit
null— the whole point of declaring the property nullable — is unreachable.This is not #419, and that distinction is the point
I want to lead with this, because it looks superficially like #419.
#419 is about optional + nullable, where there genuinely are three states — absent,
null, and a value — and Swift'sT?only models two. That needs a design (thePotentiallyAbsentdiscussion, a custom Encoder/Decoder), which is presumably why it has been open since 2023.Required + nullable has only two states. Absence is forbidden by the schema, so
nilcan only ever mean JSONnull.T?models it exactly. There is no ambiguity to resolve and no new type required — the current output simply discards information the schema provides.The
typeOverridesworkaround suggested in #419 (comment) also doesn't reach this case: it is keyed on named component schemas, and generators that emit inline request bodies have no schema name to key on.Reproduction
Generated:
No custom
encode(to:)is generated for this struct, so synthesizedCodableapplies andvote: nilencodes as an absent key rather thannull.The clearest evidence
Generating from the same document twice — once with
voteinrequired, once without — produces byte-identical Swift. Therequiredarray has no observable effect on a nullable property.For contrast, a required non-nullable open schema (
vote: {}) does generate a non-optional property with no default, andOpenAPIValueContainerencodesnilas an explicitnullviaencodeNil():So the runtime can already express "present and null" — it is only the type-assignment layer that cannot reach it, and only by way of total type erasure.
Where it comes from
Sources/_OpenAPIGeneratorCore/Translator/TypeAssignment/TypeMatcher.swift:requiredis never consulted oncenullableis true, so nullable-and-required collapses into the same representation as not-required. The property then picks up a= nildefault intranslateStructBlueprint, and the struct keepsOpenAPICodableStrategy.synthesized.Suggested fix
Scoped to
required && nullableonly, leaving #419's harder case untouched:= nildefault for a required property, so the caller must make an explicit choice.encoderather than inheriting synthesizedencodeIfPresent, sonilserializes asnull.(2) needs a custom
encode(to:)when a struct has at least one required-nullable property.OpenAPICodableStrategyalready carries custom-encoder cases for the additional-properties path, so this looks like an additional strategy rather than new architecture. Decoding needs no change —decodeIfPresentalready maps bothnulland absent tonil, and absent cannot legally occur.I'd be glad to hear whether that framing is acceptable before anyone spends time on an implementation.
Environment
Impact
Any server whose contract is "you must send this field, and
nullis a meaningful value" — clearing a vote, unsetting an image — is unreachable from a generated Swift client. The server rejects the request with a validation error for a missing required property, and there is no way to construct a conforming one short of hand-writing the body type.