Skip to content

Required + nullable properties get a = nil default and are omitted on encode, so a conforming request is impossible to construct #934

Description

@Andrew-Chen-Wang

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:

  1. Emit no = nil default for a required property, so the caller must make an explicit choice.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions