From JSON to Pydantic and TypeScript models
A poorly documented API payload is routine: a webhook or legacy-endpoint JSON shows up and someone has to write the Pydantic model or the TypeScript interface by hand. The tedious part isn't the typing, it's figuring out what the payload actually contains when the examples don't agree with each other.
[
{"id": 1, "zip": "12345", "nickname": "alice", "total": 9.9},
{"id": 2, "zip": 67890, "nickname": null}
]
Two rows, three traps:
zipcomes in as a string in one record and a number in the other. Mixed type: either the source is messy, or these are two versions of the producer. The model has to decide (strwith coercion,Union, or fix the source).nicknameisnullin one record: genuinely optional, notstr.totalonly exists in the first: a missing field is different from a null field, and the model has to reflect that.
Inferring from a single example gets all three wrong. Serious inference looks at every record and aggregates: which fields show up in all of them, which are sometimes missing, which change type. The more records you feed it, the closer the inferred model is to the real contract; one payload can't tell you which fields come and go.
Numbers widen, and that's correct
JSON has one number type, but samples tell you more:
{"total": 10} in one record and {"total": 9.9} in
another means the field is fractional and the first record just happened to
land on a whole value. The inferrer widens int to
float the moment any record shows a decimal, because the reverse
guess (typing it int from one lucky sample) produces a model that
rejects real traffic next Tuesday. The same logic applies to
str | int unions: the union is the honest reading of the
evidence; narrowing it back down is a decision you make about the source, not
something to infer away.
Date strings are a convention, not a type
Nothing in JSON says "2024-01-15T10:30:00Z" is a datetime; it's
a string that matches a convention. Inference can recognize ISO 8601 with high
confidence and map it to datetime/Date. But when the
same field shows "2024-01-15T10:30:00Z" in one record and
"15/01/2024" in another, that's not a formatting choice, it's two
producers (or one buggy one), and it deserves a warning rather than a silent
str. Ambiguous formats like 01/02/2024 can't even be
parsed without knowing the locale. The safe model types the field as a string
and flags it; the fix belongs at the source.
The result in both worlds
# Pydantic
class Item(BaseModel):
id: int
zip: str | int # mixed type: decide and normalize
nickname: str | None = None
total: float | None = None
// TypeScript
interface Item {
id: number;
zip: string | number;
nickname: string | null;
total?: number;
}
Notice that nickname and total aren't the same
thing: one is "always present, can be null", the other is "might not come at
all". A good model preserves that difference; flattening everything into
optional hides the contract. The
nullable-vs-optional distinction has its own article, including how to
handle the PATCH case where the difference carries meaning.
Nested objects become their own types (an address object
becomes an Address model referenced by the parent), and arrays
infer from their elements. For streaming logs and exports, NDJSON (one JSON
object per line) works the same way, every line is one more sample
strengthening the inference.