Skip to content

nullable is dropped on nullable references, producing a decoder that crashes on null #321

Description

@jofan

Summary

When a property is a nullable reference, the nullable is silently dropped. The generated type is the bare referenced type instead of OpenApi.Common.Nullable T, and the generated decoder uses the plain decoder with no null branch. A conforming server that sends null for that property crashes the decoder at runtime.

Nullable scalars are handled correctly, which makes the failure easy to miss: the same spec produces a correct Nullable String for a scalar and an incorrect bare type for a reference, side by side.

Both spellings of "nullable reference" are affected:

  • { "nullable": true, "allOf": [ { "$ref": "..." } ] } — the standard OpenAPI 3.0 workaround, since a sibling of $ref is ignored in 3.0
  • { "$ref": "...", "nullable": true } — the sibling form

Version: elm-open-api@0.8.0, run via npx elm-open-api@0.8.0 spec.json --output-dir out --module-name Repro.

Reproduction

spec.json:

{
  "openapi": "3.0.3",
  "info": { "title": "Nullable ref repro", "version": "1.0.0" },
  "paths": {
    "/thing": {
      "get": {
        "operationId": "getThing",
        "responses": {
          "200": {
            "description": "ok",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Thing" }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Upload": {
        "type": "object",
        "required": ["url"],
        "properties": { "url": { "type": "string" } }
      },
      "Thing": {
        "type": "object",
        "required": ["id", "note", "upload", "uploadNoAllOf"],
        "properties": {
          "id": { "type": "string" },
          "note": { "type": "string", "nullable": true },
          "upload": {
            "nullable": true,
            "allOf": [{ "$ref": "#/components/schemas/Upload" }]
          },
          "uploadNoAllOf": {
            "$ref": "#/components/schemas/Upload",
            "nullable": true
          }
        }
      }
    }
  }
}

Actual output

Repro/Types.elm:

type alias Thing =
    { id : String
    , note : OpenApi.Common.Nullable String
    , upload : Upload
    , uploadNoAllOf : Upload
    }

Repro/Json.elm:

        |> OpenApi.Common.jsonDecodeAndMap
            (Json.Decode.field
                "note"
                (Json.Decode.oneOf
                    [ Json.Decode.map OpenApi.Common.Present Json.Decode.string
                    , Json.Decode.null OpenApi.Common.Null
                    ]
                )
            )
        |> OpenApi.Common.jsonDecodeAndMap
            (Json.Decode.field "upload" decodeUpload)
        |> OpenApi.Common.jsonDecodeAndMap
            (Json.Decode.field "uploadNoAllOf" decodeUpload)

note is correct. upload and uploadNoAllOf have no null branch, so {"upload": null} fails to decode.

Expected output

    , upload : OpenApi.Common.Nullable Upload
    , uploadNoAllOf : OpenApi.Common.Nullable Upload

with the same oneOf [ map Present decodeUpload, null Null ] shape already used for scalars.

Why this one bites hard

It is not a compile error and not a warning. The generated code compiles, the type looks reasonable, and the failure only appears at runtime when the server actually sends null — which for an optional-by-design field may be uncommon in development and normal in production.

We hit it on a real endpoint whose response includes a pre-signed upload target that is null whenever object storage is not configured. The generated decoder would have crashed in exactly the deployment shape we ship to customers who run the stack themselves. We only caught it because we were auditing the generated output field by field against the contract.

Workaround

For anyone else hitting this before it is fixed: pre-process the spec to inline the referenced schema at the nullable site, keeping nullable: true on the inlined object. Elm records are structural, so the inlined anonymous record is the same type as the named alias and nothing downstream changes.

// { nullable: true, allOf: [ $ref ] }  ->  inlined target with nullable preserved
const inlineNullableRef = (schemas) => (node) => {
  if (node.nullable !== true || !Array.isArray(node.allOf) || node.allOf.length !== 1) return node;
  const [only] = node.allOf;
  if (typeof only?.$ref !== 'string') return node;
  const target = schemas[only.$ref.replace('#/components/schemas/', '')];
  const rest = Object.fromEntries(Object.entries(node).filter(([k]) => k !== 'allOf'));
  return { ...structuredClone(target), ...rest, nullable: true };
};

Thanks for the tool — the generated client saved us several thousand lines of hand-written decoders, and this is the only correctness problem we found in it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions