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.
Summary
When a property is a nullable reference, the
nullableis silently dropped. The generated type is the bare referenced type instead ofOpenApi.Common.Nullable T, and the generated decoder uses the plain decoder with nonullbranch. A conforming server that sendsnullfor 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 Stringfor 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$refis ignored in 3.0{ "$ref": "...", "nullable": true }— the sibling formVersion:
elm-open-api@0.8.0, run vianpx 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:Repro/Json.elm:noteis correct.uploadanduploadNoAllOfhave nonullbranch, so{"upload": null}fails to decode.Expected output
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
nullwhenever 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: trueon the inlined object. Elm records are structural, so the inlined anonymous record is the same type as the named alias and nothing downstream changes.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.