Nullable or optional: pick one
JSON has one way to say “no value here”: the literal
null. TypeScript has two: T | null (the field is
there, it is just null) and T | undefined (the field might not
be there at all). Most people generating types for API responses pick
whichever one shows up in the first sample and move on. That is a mistake,
and it is the kind of mistake that only hurts later.
Look at a single Stripe customer response. The address field
is always present. When the customer has not added one, Stripe sets it to
null. The shape is stable; the meaning of the value
varies.
{ "id": "cus_abc", "address": null, "email": "j@example.com" }
{ "id": "cus_def", "address": { "city": "NYC" }, "email": null }
Now compare that to a Shopify product. Some products have a vendor
field. Some simply do not include it at all.
{ "id": 1, "title": "Widget", "vendor": "Acme" }
{ "id": 2, "title": "Gizmo" } These two shapes demand different types:
interface Customer {
id: string;
address: Address | null; // always present, may be null
email: string | null; // always present, may be null
}
interface Product {
id: number;
title: string;
vendor?: string; // sometimes absent entirely
}
The | null version tells the reader “the server has
decided this field has no meaningful value this time.” The
?: version tells the reader “the server chose not to
include this field; its presence carries information.”
Collapsing them — making everything optional, as many generated types
do — loses both signals. You end up with vendor?: string
for the Stripe customer’s address, and now your code has to check two
things (is it missing? is it null?) where one would do.
When a field is always present, make it required and say what the null case means. When a field is sometimes absent, make it optional and say what absence means.
— the house styleThe discipline pays for itself the first time you destructure a response in production code:
// If the type is right, these are two different checks with two meanings.
const { address, vendor } = response;
if (address === null) { /* user has no billing address */ }
if (vendor === undefined) { /* vendor is not tracked for this product */ }
// If you collapsed everything to `T | null | undefined`, you wrote:
if (address == null) { /* missing? null? who knows */ } How the tool decides
The inference rule is narrow. From the samples it sees:
- Every sample includes the key, values are
Tornull→T | null. - Some samples omit the key →
T?. - Both — sometimes absent, sometimes present and
null→T | null | undefined. A signal that the API surface is messy; confirm intent before trusting the type.
The default matters because the alternative — erring on “make everything optional and nullable” — is a false economy. It makes every consumer downstream handle states the server will never produce. Types exist to remove possibilities; if they add them, they are working against you.
One more subtlety. undefined in a type often gets read as
“the value might literally be undefined at runtime”,
but in JSON it is purely structural: undefined does not exist
in JSON, so the optional case is about the key being absent, not
about the value being undefined. If you are parsing with
JSON.parse, a missing key becomes a missing property on the
resulting object, which, when read, gives you undefined. The
runtime behavior converges. But the intent you encode is different, and
that intent is what your teammates read.
The tool that emits these types has to make this call for you, because it
cannot read your mind about whether a field being null in your
sample is typical or exceptional. The best it can do is record what the
samples actually show, and not widen beyond that. If you want your types to
reflect contracts rather than samples, paste enough samples to cover the
real variation, or paste the JSON Schema the API publishes, or override.
The short version: nullable and optional are different promises. Treating them as the same promise is a cost you pay every time someone reads the type.