Repository navigation
fix: refuse a null for an optional, non-nullable field (#4) - #5
Merged
antoinetoussaint-byte merged 1 commit intoSep 13, 2026
Conversation
A field the contract declares optional and not nullable may be absent and
may not be null, but generated as *T it accepted both. encoding/json sets
a pointer field to nil for an explicit null without ever consulting an
unmarshaler, so {"count":null} decoded without error and was then
indistinguishable from the key being absent. Every optional, non-nullable
string, integer, boolean, object and array carried the hole.
Such a field is now a Present[T], a value and not a pointer, which is what
makes the decoder consult UnmarshalJSON at all; it refuses null. Absent is
the zero Present, which IsZero reports and omitzero omits, so absent and
set are unchanged on the wire. The carried value is reachable only through
the Value constructor and Get, so no literal can spell a key that is
absent and carries a value at once. Marshaling an absent Present is an
error rather than a null: every generated field carries omitzero, so
reaching the marshaler absent means the value is being encoded where a key
cannot be omitted, and null would both stand in for absent and be a value
the declaration forbids.
The contract's four optional/nullable combinations now have four distinct
Go spellings; optional and nullable no longer share *T.
The issue offered two places for this — a carrier type or raw-payload
validation in the harness. The harness does not exist yet, so the carrier
is the only implementable option, and it is the one a caller decoding the
bindings directly cannot bypass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4.
Summary
optionaland notnullablemay be absent and may not be null, but*Taccepted both.encoding/jsonsets a pointer field to nil for an explicitnullwithout ever consulting an unmarshaler, so{"count":null}decoded without error and was then indistinguishable from the absent key — for every optional, non-nullablestring,integer,boolean, object and array.Present[T], a value and not a pointer, which is what makes the decoder consultUnmarshalJSONat all; it refusesnull. Absent is the zeroPresent, whichIsZeroreports andomitzeroomits, so absent and set are unchanged on the wire. The value is reachable only through theValueconstructor andGet, so no literal can spell a key that is absent and carries a value at once.optionalandnullableno longer share*T.The issue offered two places to fix this — a carrier type, or raw-payload validation in the harness. The harness does not exist yet (blocked on core#472), so the carrier is the only implementable option; it is also the one a caller who decodes the bindings directly cannot bypass.
Author-facing change
Every optional, non-nullable field changes spelling:
Risk: merge order against
ea21ad1ea21ad1— the commit the issue cites for the array half of this defect — is pushed to the branch of the already-merged PR #3 and carries no PR of its own, so it is not inmainand not in this base. Worth someone deciding what happens to it; this PR does not touch it.It reworks the same two seams (
fieldType, the carrier declarations) to make arrays aListandOptional's states constructor-only. So if that work is ever opened as a PR it will conflict textually with this one infieldTypeand the declaration block. The two are complementary in intent — same defect, array half vs scalar half — and the names chosen here (Present,Value) deliberately avoid colliding with theList/Absent/Null/Setthat commit introduces.Because this PR is written against
main, an optional array here becomesPresent[[]T]rather than the bareList[T]that commit uses. Both refuse the forbiddennull; they differ on the separate array-encodes-nulldefect, which isea21ad1's scope and is untouched here.Test plan
go build ./...,go vet ./...,gofmt -l .all cleango test ./...passesTestOptionalAndNotNullableIsNeverAPointer— all five optional, non-nullable value types generatePresent[…], none a pointerTestNullIsRefusedForAnOptionalFieldThatIsNotNullable, run inside the generated package against the real compiler andencoding/json—{"count":null}and{"options":null}are refused, while anullfor anullableand for an optional-and-nullable key is still acceptedPresentreaches the wire as"count":7; absent keys are still omittednullfails the generated package's refusal test🤖 Generated with Claude Code