Optional vs Nullable Fields in JSON
An API returns {"id": 1, "name": "Ada"} on Monday and {"id": 1, "name": "Ada", "nickname": null} on Tuesday. Your generated type says nickname: string, your code calls .toUpperCase() on it, and production throws. Optional and nullable are two different states of a JSON field, most type generators collapse them into one, and the collapse is where the bug lives.
Two Different Absences
A key can be missing from an object. A key can be present with the value null. JSON treats these as distinct, and so does every type system you would generate code into.
| State | JSON | Meaning |
|---|---|---|
| Present, typed | {"team": "core"} | You have a value |
| Present, null | {"team": null} | The field exists and is explicitly empty |
| Absent | {} | The field was not sent |
team means leave it alone. A PATCH body that sends "team": null means clear it. Collapse those into a single type and your client cannot express the difference at all.
One Sample Is Not Enough
This is where most generators go wrong. Feed a tool a single object and it can only describe that object. Take this array of three records:
A generator that reads only element [0] produces this:
Both of the interesting fields are wrong. nickname is typed as the literal null, because that is all the first record showed, so assigning the string "Linus" to it will not compile. team is typed as a required string, which is wrong twice over: it is missing from record two and null in record three.
Merging all three elements gives the correct answer:
nickname appears in every record, so it stays required, but one record had null, so the type is nullable. team is missing from one record, so it gets ?, and null in another, so it also gets | null. Two independent facts, tracked separately. The JSON to TypeScript converter merges every element of every array instead of sampling the first, which is why it lands on this output.
The Same Distinction in Four Languages
Each target spells it differently. Same JSON, four generators:
In TypeScript, optional is a property of the key (?) and nullable is a property of the value (| null). Zod splits them across two chained methods. Pydantic encodes it in the presence of a default value. Go has no syntax for either and leans on pointers plus a struct tag.
Zod: nullable Does Not Imply optional
Running these schemas against real payloads on Zod 4.3.6:
| Input | Schema | Result |
|---|---|---|
{ nickname: null } | z.string().nullable() | passes |
{} | z.string().nullable() | fails |
{} | z.string().nullable().optional() | passes |
undefined rather than saying the key is missing:
If you see that on a field you thought was optional, you wrote .nullable() where you needed .nullish(), or the .nullable().optional() pair the generator emits. Generate the schema from a real payload and you get the right combination by construction instead of by memory.
Pydantic: Optional Is About the Type, Not the Requirement
This is the most misread annotation in Python. Optional[str] is nothing more than str | None. It says which values are allowed, not whether the key has to be there. On Pydantic 2.13.5:
nickname has no default, so it is required and must be passed, even when the value you pass is None. team has = None, so it can be omitted entirely. The default is what makes a Pydantic field optional. The Optional wrapper never did. The JSON to Pydantic generator adds the default only for keys that were genuinely absent from some record, which is exactly the fact the annotation alone cannot carry.
Go: The Distinction Does Not Survive
Go is the honest loser here. encoding/json unmarshals a missing key and an explicit null into the same nil pointer, so *string tells you nothing about which one arrived.
omitempty does not help, because it only affects the marshal direction: it drops the key when writing, which is the correct behavior for a field that was optional on the way in. If you need to tell the two apart while reading, the field has to be json.RawMessage so you can compare against the literal bytes null yourself. That is verbose, so use it on the two or three fields where the difference actually changes behavior, typically a PATCH handler. Generate the struct first, then add the escape hatch by hand.
A Practical Rule
Sample width beats sample depth. Two hundred records from one endpoint tell you more about optionality than a single beautifully documented example, because optionality is only observable across records. When you paste a payload into a generator, paste the whole array, not the one record you happened to copy first.
And when a field turns out to be both optional and nullable, resist the urge to tidy it into one or the other. team?: string | null looks noisy. It is also true.