You paste a 400-line JSON sample into a converter, and out comes a clean interface User { ... } with the right shape, the right optionality, and even a type Status = "active" | "pending" union you didn't ask for. It feels like magic. It isn't — it's a pretty mechanical walk over the JSON tree, applying a small set of rules. Once you understand those rules, you also understand why the same tools sometimes hand you back a type that's almost, but not quite, what you wanted.
This is a tour through how JSON-to-TypeScript inference actually works, where it gets confident, and where it has to guess.
Why You Can't Just `type User = typeof json`
The obvious instinct: parse the JSON, hand TypeScript the value, let inference figure it out. That actually works — partially:
const user = JSON.parse(jsonString);
// user: any ← not helpful
JSON.parse returns any. TypeScript has no idea what's inside the string at compile time, so it gives up. You can do better with as const on a literal:
const user = {
id: 7,
name: "Ada",
active: true,
} as const;
// user: { readonly id: 7; readonly name: "Ada"; readonly active: true }
Closer, but now id is the literal 7, not number. That's not a reusable type — it's a description of one specific value. What you actually want sits between these two extremes: a type that says "this object has an id of type number, a name of type string, an active of type boolean." That's structural inference, and it's exactly what tools like QuickType and our own JSON to TypeScript generator do for you.
The Core Algorithm: Walk and Widen
Every type-inference tool follows the same skeleton. Given a JSON value, recurse:
- Primitive? Map it: string →
string, number →number, boolean →boolean, null →null. - Object? Recurse into each property, build an interface where each key maps to the inferred type of its value.
- Array? Infer the type of each element, then merge those types into a single element type for the array.
That last step — merging — is where the interesting decisions live. The rest is bookkeeping. Take this input:
{
"id": 42,
"name": "Ada",
"active": true,
"tags": ["admin", "early-access"]
}
Walking the tree gives you:
interface Root {
id: number;
name: string;
active: boolean;
tags: string[];
}
No surprises. The fun starts when arrays contain different shapes, or when fields are sometimes missing.
Handling Optional and Nullable Fields
A real API response is rarely uniform. Consider a list of users:
[
{ "id": 1, "name": "Ada", "email": "ada@example.com" },
{ "id": 2, "name": "Lin", "email": null },
{ "id": 3, "name": "Sam" }
]
Three records, three slightly different shapes. A naive tool would emit three separate interfaces. A good one merges them by walking each property across all samples and asking two questions:
- Did this property appear in every sample? If not, it's optional (
email?:). - Was the value sometimes
null? If so, the type is a union withnull.
Combined, you get:
interface User {
id: number;
name: string;
email?: string | null;
}
email?: string | null reads as: the property may be absent, and if present, it can be a string or null. Some tools prefer email: string | null | undefined instead — semantically equivalent for strictNullChecks, but the ?: form is what the TypeScript handbook recommends for optional properties on object types.
If your sample only contains one record, the tool can't see this variation, and you'll get a stricter type than the API actually returns. This is why feeding multiple samples — or the largest representative response you have — produces dramatically better output.
Arrays of Mixed Shapes
Arrays are where inference really earns its keep. If every element has the same shape, the result is T[]. If they differ, the tool has to decide between three strategies:
Union of types. Most direct — emit (A | B)[]:
[
{ "type": "text", "value": "hi" },
{ "type": "image", "url": "/cat.png" }
]
type Item =
| { type: "text"; value: string }
| { type: "image"; url: string };
That's a discriminated union, and it's exactly what TypeScript's narrowing was designed for. If item.type === "text", TypeScript knows item.value exists.
Merged super-shape. Combine all properties into one interface, marking the variants as optional:
interface Item {
type: "text" | "image";
value?: string;
url?: string;
}
Less precise but flatter. Some tools default to this because it produces fewer named types. It also produces worse compile-time checks — TypeScript can't tell you that value and url are mutually exclusive.
Per-shape interfaces with names. Some tools detect a discriminator field and emit named types: TextItem, ImageItem, plus a union. Best output, hardest to do automatically without heuristics.
When you're using a generator, look at how it handles the messy cases — that's the real differentiator. The clean ones rarely matter.
Numbers, Dates, and Other Lies
JSON has no distinct types for integers, floats, dates, UUIDs, or enums. Everything is number, string, boolean, null, object, or array. A type generator can't tell from 42 whether you meant an integer ID or a measurement, and it can't tell from "2026-05-08T12:00:00Z" whether you wanted a Date or a string.
A few common heuristics tools apply:
- A string field where every sample matches an ISO 8601 pattern → suggest
stringbut flag it (some tools emitDateif you opt in, but JSON doesn't carry dates natively, so this is risky). - A string field with a small number of distinct values across samples → emit a literal union:
"active" | "pending" | "closed". - A number field where every sample is an integer → emit
number(TypeScript has nointtype; this distinction lives in your runtime validator, not your types).
The literal-union case is the most useful. Given:
[
{ "status": "active" },
{ "status": "pending" },
{ "status": "active" }
]
A tool with this heuristic emits:
type Status = "active" | "pending";
interface Root { status: Status; }
Two distinct values, both string literals — the tool guesses you have an enum. With ten distinct values it would back off to string, because at that point you probably have free-form text and a literal union would be more annoying than helpful.
Static Types Aren't Validation
Here's the trap. TypeScript types disappear at runtime. If your API contract says email: string but the server actually returns email: null, your type says one thing and your value does another. The compiler is satisfied; the user gets Cannot read properties of null.
This is why runtime validation libraries exist. Zod is the popular pick — you define a schema once, get a TypeScript type and a runtime validator from the same source:
import { z } from "zod";
const User = z.object({
id: z.number(),
name: z.string(),
email: z.string().nullable().optional(),
});
type User = z.infer<typeof User>;
// User = { id: number; name: string; email?: string | null }
const result = User.safeParse(await fetch(...).then(r => r.json()));
if (!result.success) { /* validation failed */ }
That's why we ship a JSON to Zod schema generator alongside the TypeScript one. Same inference walk, different output. For an even more language-agnostic contract you can publish, the JSON Schema generator emits the IETF standard format that backend services in any language can validate against.
The rule of thumb: types for your editor, schemas for your network boundaries. Don't ship code that trusts a remote JSON.parse result without one.
Naming, Nesting, and Clean Output
A generated type is only useful if you can read it. Tools handle this with a few patterns:
Inline vs. extracted. A nested object can be inlined:
interface User {
id: number;
address: { street: string; city: string };
}
Or extracted into a named child type:
interface Address { street: string; city: string }
interface User { id: number; address: Address }
Extraction wins once a shape repeats — if Address appears under billingAddress and shippingAddress, you want one type. Most tools detect structural duplicates and dedupe automatically.
Naming root types. The tool has to call the top-level interface something. Conventions: Root, RootObject, the JSON file's name, or a name you provide. Always rename it after generation; Root is rarely descriptive once the type leaves the file it was generated in.
Property name sanitization. JSON keys can be "first-name" or "123foo" — both legal JSON, neither valid TS identifiers. The generator quotes them: "first-name": string;. Accessing them requires bracket notation: user["first-name"]. If you control the JSON, prefer camelCase keys.
If you're working in another language, the same inference algorithms power JSON to Go struct, JSON to Rust struct, and JSON to C# class generators. The output styling differs (struct tags, attributes, snake_case fields), but the underlying tree walk is identical.
When Generators Get It Wrong
Three failure modes show up over and over:
Empty arrays. Given "tags": [] with no samples, the tool has to guess. Most emit tags: any[] or tags: never[] or tags: unknown[]. None are right; only more samples would help. If you see any[] in generated output, that's a flag to feed in a non-empty example.
Single-sample assumptions. A field that's null in your only sample becomes null in the type, when it's actually string | null. A field that's missing in your only sample is omitted entirely from the type, when it might be optional. Always test against multiple representative responses.
Recursive structures. A tree of comments where each comment has a replies: Comment[] is a self-reference. Some generators handle this; others emit a depth-limited expansion. Verify with a sample that includes nesting.
The fix in all three cases is the same: the generator is showing you what it can prove from your input. If you want more, give it more.
For day-to-day API work, the loop that produces the best types looks like this. Capture a few real responses including edge cases (empty lists, null fields, error responses), combine them into a single JSON array — most generators accept array input and merge the shapes for you — and generate with JSON to TypeScript. If the optionals look wrong or the unions look too wide, revisit your samples. For anything crossing a network boundary, generate a Zod schema in parallel and validate at the edge.
The MDN JSON reference is worth bookmarking for the parsing details — particularly the reviver argument, which lets you hook in lightweight runtime coercions before validation. If your data looks shaky, format it through the JSON Formatter first to catch trailing commas or duplicate keys before they confuse the generator. And once you have a type, the API Rate Limiting post is a good follow-up if you're now thinking about how to consume the API without getting throttled.
Type generation isn't magic — it's a disciplined walk through your data with a handful of merging rules. Knowing the rules means knowing when to trust the output and when to give the tool more samples to work with.
FAQ
Why does the generator emit `any[]` for my empty array?
The generator can't infer the element type from zero samples — [] is consistent with any element type, including impossible ones. Most tools default to any[] to avoid blocking the conversion; some emit unknown[] (safer) or never[] (literal but unhelpful). If you see any[], feed in a non-empty example or manually specify the element type after generation.
Should I use `interface` or `type` for generated JSON shapes?
Either works for plain object shapes. interface is slightly preferred for JSON because of declaration merging (you can extend the type later in another file). type is required when the generator emits unions like "active" | "pending" because interfaces can't represent unions. Most generators let you toggle the output style — pick whichever your team's existing code uses.
How many JSON samples should I feed the generator for good output?
The minimum useful sample size is 3–5 representative responses, including at least one with optional fields missing and one with null values. Below that, the generator can't tell which fields are optional vs. always-required, and you'll get over-strict types. For pagination endpoints, include both an empty result and a populated one — the empty case reveals optional metadata fields.
Can the generator detect if a string is actually a date?
Some can — tools with date heuristics check for ISO 8601 patterns (YYYY-MM-DDTHH:mm:ssZ) and either flag the field as string (recommended) or emit Date (risky, since JSON.parse won't actually return a Date). The safest default is string plus a runtime parser. If you need Date objects, use a Zod schema with .transform(s => new Date(s)) rather than relying on the generated type to do the work.
Why do my generated types have `Root` or `RootObject` everywhere?
That's the default name most generators give the top-level interface. It's almost never what you want — rename it to something descriptive (UserResponse, ProductList) immediately after generation. Some tools accept a custom name as input; for tools that don't, a find-and-replace in the generated file is the workflow. The internal nested types are usually named after their property keys.
What about JSON keys that aren't valid TypeScript identifiers?
Keys like "first-name" or "123abc" are quoted in the generated type: "first-name": string;. Access them with bracket notation: user["first-name"]. The generator can't rename them automatically without breaking the wire format, so the kebab-case keys persist in your code. If you control the API, switch to camelCase for the JS/TS side; if not, accept the quoted-key syntax.
Is JSON Schema better than TypeScript types for API contracts?
For language-agnostic contracts yes — JSON Schema validates the same data in any language (Python, Go, Rust, Java) using the same schema file. TypeScript types only work in TypeScript. For internal TS-only services, generated types are simpler. For public APIs or polyglot systems, JSON Schema (or OpenAPI, which uses JSON Schema internally) is the right contract surface, with generated TS types derived from it.
What's the right way to handle generated types when the API changes?
Treat the type file as generated artifact, not source code: regenerate from a fresh API sample and review the diff. Don't hand-edit generated types — your changes get overwritten on the next regen. The clean workflow is: capture sample responses to a samples/ folder, run the generator on commit hook or as a manual step, commit the generated .ts file, and review the diff in code review. If types diverge from runtime data, your samples are stale.