From JSON to TypeScript Interface: Inferring Types You Can Trust
Turn a JSON sample into clean TypeScript interfaces. How type inference works, when fields become optional, how nested objects and arrays are typed, and how to type API responses.
From JSON to TypeScript Interface: Inferring Types You Can Trust
Most TypeScript projects start the same way at the network boundary: a fetch call, a .json(), and a variable typed any because nobody wanted to hand-write a 40-field interface from a docs page. That any is a hole in your type safety, and it tends to stay there for the life of the codebase.
There is a faster path. Take one real JSON response, infer the shape from it, and paste the result into your project as a proper export interface. This post walks through how that inference actually works — how each key becomes a typed field, when a field is marked optional, how nested objects and arrays are handled, and how to apply all of it to real API responses. You can follow along in the JSON to TypeScript interface generator.
Every key becomes a typed field
The base rule is the simplest one: each key in a JSON object becomes one field in an interface, and the field's type is read from the value sitting there.
A string value gives you string. A number gives you number — and this is worth stressing, because JSON has no integer-versus-float distinction. A field that is always 42 still types as number, not the literal 42. A boolean gives you boolean. A null gives you a fallback type, which you can set to unknown (the safe default that forces you to narrow before use) or any for legacy codebases.
So this object:
{ "id": 1, "name": "octocat", "site_admin": false }
becomes:
export interface Root {
id: number;
name: string;
site_admin: boolean;
}
One key, one field, type read from the value. Keys that break TypeScript's identifier rules — ones that start with a digit, or contain hyphens or dots — get quoted automatically, so 2fa_enabled becomes "2fa_enabled" and x-api-key becomes "x-api-key". The output compiles in strict mode without you touching it.
Nested objects become their own interface
When a value is itself an object, it does not get inlined as an anonymous shape. It is lifted into its own named interface, and the parent field references it by name. The child interface is named after the parent: a plan object inside GitHubUser becomes GitHubUserPlan.
This keeps the output readable and reusable. You can import the nested interface independently, and a deeply structured response reads as a flat list of declarations rather than one wall of nested braces.
Arrays become T[], and elements get merged
Arrays are where the real inference happens. An array of scalars unions its element types: [1, "a", null] becomes (number | string | null)[], with null pinned to the end of the union for readability.
An array of objects is more interesting. Rather than emit one interface per element, every object folds into a single shape. Feed it [{ "a": 1 }, { "a": 1, "b": 2 }] and you get one interface with b marked optional — not two competing interfaces you have to reconcile by hand. That single merged shape is exactly what you want when typing a list endpoint that returns slightly varied records.
Optional fields come from the array, not the object
Here is the rule that trips people up: optional detection only happens across an array of multiple objects. If a key appears in some elements but not others, it gets the ? marker.
A single object root has no ambiguity at all — every key it has is, by definition, present, so every field is required. If you paste one object and expect optionals, you will not get them. The fix is to wrap your sample as [{...}], or paste several real samples as an array so the generator can see which fields are actually situational.
This is genuinely useful for webhook and event payloads. Vendor docs love to say "this field is sometimes present" without telling you which ones. Log three real payloads, paste them as a JSON array, and the optional markers tell you precisely which keys you can rely on and which you must guard against.
A worked example
Take this small array — two user records where the second one carries an extra bio:
[
{ "id": 1, "login": "octocat", "plan": { "name": "free", "seats": 1 } },
{ "id": 2, "login": "hubot", "bio": "robot", "plan": { "name": "pro", "seats": 5 } }
]
Set the root name to User and the generator produces:
export interface User {
id: number;
login: string;
bio?: string;
plan: UserPlan;
}
export interface UserPlan {
name: string;
seats: number;
}
Read it back against the four rules. Every key became a field. plan was lifted into its own UserPlan interface, named after its parent. bio appears in one of two elements, so it is optional. And the whole thing is User[] at the call site — one shape covering both records, no manual merging.
Typing an API response end to end
Once the interface exists, wiring it to a real call is a one-liner. For a GitHub user fetch:
const res = await fetch("https://api.github.com/users/octocat");
const user = (await res.json()) as GitHubUser;
Now user.plan.seats autocompletes, and a typo like user.platn is a compile error instead of a runtime undefined. When GitHub adds a field next year, you re-paste the new response, regenerate, and diff the file to see exactly what changed.
One caveat worth saying plainly: an interface is a compile-time promise, not a runtime guarantee. The as GitHubUser cast tells the compiler what to expect, but at runtime the API can return anything and TypeScript will not notice. For anything crossing a trust boundary, pair the generated interface with a runtime validator such as zod or valibot. The generated type is your map; the validator is the customs check at the border.
How I actually use this
I keep a src/types/ folder where every external response shape lives as a generated interface, and I treat regenerating them as part of integrating any new endpoint. My habit is to curl the endpoint once, paste the response, set a meaningful root name, and drop the output straight into a file named after the service. The part I appreciate most is the diff workflow: when a vendor quietly changes a payload, re-pasting and looking at the git diff on that one file shows me the change in seconds — far faster than discovering it through a production bug. The optional-field detection has caught more than one "always present" field that turned out to be conditional in the wild.
Before you paste
A few things to keep in mind. JSON numbers all collapse to number, so if you need narrower literal types, reach for a runtime validator. The strict JSON.parse underneath rejects trailing commas, comments, and unquoted keys — if you're working with JSON5 or a config-with-comments file, clean it up first, or run it through the YAML to JSON converter as a pre-step, since YAML tolerates comments and is a JSON superset. And remember the single-object rule: one object in means everything required out.
That is the whole model — keys to fields, nested objects to named interfaces, arrays to T[], and optionals inferred from which keys survive across a sampled array. Paste a real response into the JSON to TypeScript interface generator and you will have usable types before your coffee cools.
Made by Toolora · Updated 2026-06-13