Understanding JSON → TypeScript
A sample is a guess, not a contract.
What a JSON-to-TS inferrer is really doing, and the choices it makes that change the types you end up with.
Inference is induction.
Every JSON-to-TS converter is doing the same thing: read one or more sample documents, induce a structural type that's consistent with what it saw. It's pattern-matching, not proof. A field absent from the sample is assumed never to exist; a field that only appears as a string is assumed always to be a string. The inferred type is as accurate as the sample is representative.
type = ⋃ shapes observed in samples
Optional vs missing vs null.
JSON allows three things that TypeScript treats as different: a key that's absent entirely, a key whose value is the literal null, and a key whose value is the string "null". A good inferrer distinguishes "always present" → required field; "sometimes absent across samples" → field?: T; "sometimes literal null" → field: T | null. Many simple inferrers collapse the second and third into the same thing, which is wrong: a server that returns {} and one that returns {"x": null} behave the same to the runtime but differently to strictNullChecks.
Arrays of unions.
When an array contains items of different shapes — a list of events of different types, a heterogeneous result page — the right inference is a discriminated union. Find the field that varies between shapes and consistently identifies which is which (often type, kind or __typename); emit { type: "A"; ... } | { type: "B"; ... }. Naive inferrers will merge all the keys into one type with every field optional, losing the discrimination.
A worked inference.
Sample: { "id": 7, "title": "Hi", "tags": ["a","b"], "author": null }. A reasonable inferrer emits { id: number; title: string; tags: string[]; author: null }. If a second sample shows "author": {"name": "Q"}, the inferrer widens to author: null | { name: string }. If a third sample omits tags entirely, it widens to tags?: string[].
Single sample
One document, all fields treated as required
Most inferrers default to required because that's what they observed.
{ id:7, title:"Hi", tags:["a"], author:null }
= { id: number; title: string; tags: string[]; author: null }
After two samples
One has tags, one doesn't
Field absent in any sample becomes optional.
merge widens tags into optional
= { id: number; title: string; tags?: string[]; author: null | ... }
Numbers, dates, and the literal-vs-widened question.
A field with sample value 42 could be inferred as number (widely useful) or as the literal type 42 (rarely useful, occasionally correct for enum-like values). A field like "2026-05-13T12:00:00Z" is syntactically a string but conventionally a date; some inferrers offer to emit Date or a branded type. Tightening is a runtime cost: if the inferrer emits Date, your code has to parse the field on every JSON decode. Inferrers that default to string with a comment are usually right.
Snake_case vs camelCase.
Most JSON APIs use snake_case key names; most TypeScript code uses camelCase. The inferrer has to decide between three options: keep the names as they came in (everything quoted, lints complain), rename to camelCase (clean types but the JSON parse step needs a mapper), or emit both via a transform. Pick a single convention across the project and configure the inferrer accordingly. Don't mix: half-snake half- camel types are a source of constant bugs.
What the converter isn't.
The output is a starting point. It cannot tell you that id: number is actually a primary key, that email: string should be validated, that status: string is really one of seven enum values, or that the API will one day add a field. Review the inferred types and tighten them where domain knowledge allows: literal unions instead of bare strings, branded types for IDs, tuples instead of arrays when the length is fixed. The inferrer cannot know any of those things; you can.