JSON to TypeScript Converter — Interfaces, Unions and Optional Keys From Real Samples
Paste a JSON response and get TypeScript declarations for it: nested interfaces, array element types, a union where the records really do differ, a question mark on keys some objects lack, and quoted names for keys that are not identifiers. Every caveat the sample leaves open is listed instead of guessed away, and what you paste never leaves this device.
Declarations
A type generated from one response is a claim about that response, not about your API. So every place the sample proves too little goes into a caveat line instead of being quietly filled with an invented type.
From JSON shape to declaration
What each shape of JSON becomes
| What you pasted | What you get |
|---|---|
| an object | interface Name { … }, nested objects as their own interfaces named from the key path |
| an array where every record has the same keys | one interface, and Name[] |
| an array whose records have different keys | one merged interface with ? on the keys not every record has — or, when a key clearly tells the kinds apart, a union of interfaces (that is the option to switch off) |
| an array of strings, numbers or booleans | string[], number[], boolean[] |
| an array of arrays | the element types flattened together, number[][] for a matrix |
| the same key holding different kinds | string | number, so both are still assignable |
null in some records |
| null on that property, which is not the same as an optional key |
| a key missing from some records | foo?: string, counted in the “Optional keys” statistic |
[] (empty array) |
unknown[] plus a caveat: an empty array says nothing about its elements |
{} (no keys) |
Record<string, unknown> plus a caveat — and note that interface Empty {} would be worse, because TypeScript’s {} accepts almost anything |
| a number | number. TypeScript has no integer type and no decimal type, only this one double |
? and | null are two different facts
Given [{"id":1},{"id":2,"note":"hi"}], the output is:
interface RootItem {
id: number;
note?: string;
}
note appears in 1 of the 2 records, so the key may be absent — that is what ? means. Now consider [{"note":"hi"},{"note":null}]: here the key is always present, so it is not optional, and the type is note: string | null. Reading a response that contains null as if the key were missing is the classic way to write res.note.length against null; the two are kept apart because your data keeps them apart.
Turn the optional box off and every key is emitted as required, which is a lie the compiler will enforce: the caveat line tells you how many keys it covers.
One key can split the records apart
An array like [{"type":"dog","bark":true},{"type":"cat","meow":true}] is not one shape with optional fields — it is two shapes, and merging them would let a record with bark and no type pass. So records are grouped by their key sets, and when a key is present in every record with exactly one string value per group, that key is treated as a discriminator: each group gets its own interface, and the discriminator becomes a literal type (type: "dog") rather than string. That is what makes a switch (item.type) narrow properly.
When the records have different key sets but no key tells them apart, splitting them would give you RootItem and RootItem2 with no way to know which is which, so they are merged into one interface and ? goes on the keys that not every record has. The caveat line names the key sets it found — {id} vs {id, note} — which is the honest version of “your sample has sparse fields”. Turn the option off and even a real discriminator merges, and the caveat says so; more than 6 kinds in one array merges too, because a union of 7 unnamed shapes is not something a person should read.
Where the names come from
Interface names are built from your key names, not from anything the values say. user_profiles under data becomes DataUserProfile. Keys that hold an array are singularised for the element type, so items gives RootItem and categories gives RootCategory — that is a spelling heuristic, and the only guess the naming makes; it changes identifiers, never types. media and address keep their name and gain Item, because dropping a trailing s from those would be wrong.
- Two different paths that produce the same name get
2,3suffixed, and the caveat says which collided. - Keys in Chinese, Japanese, Korean or Cyrillic stay as they are in the interface name, because TypeScript accepts them as identifier characters.
- A name that would start with a digit gets an underscore in front of it.
- The root name box is checked: an empty value falls back to
Root, and so does one that is not an identifier or is a word a declaration cannot use — the JavaScript reserved words plus TypeScript’s own (interface,type,namespace,string,keyof, and the rest).interfaceis the one people try, andinterface interface {}does not parse.
Keys that are not identifiers get quoted
{"user-id": 7, "2024": 1, 🎉: true} produces "user-id": number;, "2024": number; and the emoji quoted as well. Quoting is only about the name, so the property is still reachable as obj["user-id"]. Reserved words are not quoted: default, type and class are all legal property names in an interface, and TypeScript says so — only the root type name has to dodge them.
Two things the browser decides:
- Duplicate keys. If the text has
"a":1and"a":2in the same object,JSON.parsekeeps the last one — that is the parser’s rule, and the generated type showsa: numberwith nothing about the first value. - Key order. The declarations list properties in the order
JSON.parsehanded the keys back, and that order puts integer-like keys first:{"user-id":1,"2024":2}comes out as"2024"then"user-id". Ordering carries no meaning in a TypeScript interface, so nothing is reordered against the parser. - Broken input. When the JSON is invalid the message quoted on screen is the browser’s own wording, including its phrasing and position, because rewording it would hide where it came from.
Big integers are already gone
A JSON number with more digits than a double holds (anything above 9,007,199,254,740,991) has usually been rounded before any reader gets to it — JSON.parse hands back an IEEE-754 double, so 9007199254740993 arrives as 9007199254740992. The raw text is then scanned, the count of changed numbers is reported with one example quoted, and the type stays number because that is the truth. If an id needs all its digits, it should be a string in the API, and this is the caveat to take back to whoever wrote it.
Questions
Q: Is the output valid TypeScript?
It is declarations only — interface and type, no imports, no runtime code, no decorators. Ordering does not matter: TypeScript hoists type declarations, so an interface may be used above the line where it is written. Tick export when the file is a module.
Q: Why did it invent a field I don’t always get? Because one sample cannot prove absence. Only what the pasted text shows becomes a type: a key that never appears in the sample simply does not appear in the interface. Paste one record of each kind you have ever seen.
Q: What are the limits?
1,500,000 characters of input, 400,000 values walked, 48 levels of nesting, 200 interfaces, 6 shapes per array. Past a limit the walk types what it stopped at as unknown and says so rather than producing a plausible-looking file.
Q: Can I use this for JSON Schema or OpenAPI?
No. A schema is a different input with different rules ($ref, oneOf, formats), and reading one as if it were data would produce a type describing the schema instead of your payload.
Q: Does my JSON get uploaded? Parsing, grouping and printing the declarations are all loops that run in your browser: no server is asked to do any of it, and what you paste never leaves this device.