Skip to content

fix: refuse a null for an optional, non-nullable field (#4) - #5

Merged
antoinetoussaint-byte merged 1 commit into
mainfrom
issue-4-an-optional-non-nullable-scalar-decodes-an-explicit
Sep 13, 2026
Merged

antoinetoussaint-byte merged 1 commit into
mainfrom
issue-4-an-optional-non-nullable-scalar-decodes-an-explicit

Conversation

@antoinetoussaint-byte

Copy link
Copy Markdown
Contributor

Closes #4.

Summary

  • A field the contract declares optional and not nullable may be absent and may not be null, but *T 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 absent key — for every optional, non-nullable string, integer, boolean, object and array.
  • Such a field is now 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 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.
  • 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 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:

Count Present[int64] `json:"count,omitzero"`   // was: Count *int64

count, ok := in.Count.Get()   // ok is false when the key was absent
out := Output{Count: Value(int64(7))}

Risk: merge order against ea21ad1

ea21ad1 — 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 in main and 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 a List and Optional's states constructor-only. So if that work is ever opened as a PR it will conflict textually with this one in fieldType and 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 the List / Absent / Null / Set that commit introduces.

Because this PR is written against main, an optional array here becomes Present[[]T] rather than the bare List[T] that commit uses. Both refuse the forbidden null; they differ on the separate array-encodes-null defect, which is ea21ad1's scope and is untouched here.

Test plan

  • go build ./..., go vet ./..., gofmt -l . all clean
  • go test ./... passes
  • New TestOptionalAndNotNullableIsNeverAPointer — all five optional, non-nullable value types generate Present[…], none a pointer
  • New TestNullIsRefusedForAnOptionalFieldThatIsNotNullable, run inside the generated package against the real compiler and encoding/json — {"count":null} and {"options":null} are refused, while a null for a nullable and for an optional-and-nullable key is still accepted
  • Encoding side — a set Present reaches the wire as "count":7; absent keys are still omitted
  • Mutation-checked both directions: restoring the pointer fails 4 tests, and letting the carrier accept null fails the generated package's refusal test

🤖 Generated with Claude Code

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>
@antoinetoussaint-byte
antoinetoussaint-byte merged commit 21f9d95 into main Sep 13, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An optional, non-nullable scalar decodes an explicit null as absent

1 participant