Turning an API response into TypeScript types
Typing a payload by hand is slow, and the errors are the quiet kind. You transcribe fourteen fields correctly, mark the fifteenth as a string when the API sends a number, and nothing complains until a customer hits the path where the value is compared. The generator above reads the sample instead of your memory. What follows is what the four output styles buy you, what a JSON sample never tells you, and where the inference is guessing.
Four ways to land the same payload in TypeScript
Pick by what has to happen after the response arrives. Code drawing a chart from data you already trust wants nothing more than field names. A form posting to a third party API wants the response checked before a single field is read.
| Output | What you get | Runtime cost | Reach for it when |
|---|---|---|---|
| Interface | Named shapes, extendable, merged across declarations | None, erased at build time | The default. Public shapes a library or another team extends |
| Type alias | The same fields, with unions and intersections available | None, erased at build time | The shape needs a union, a mapped type, or a name nobody should reopen later |
| Class | Real objects, a constructor, nested instances built for you | Ships in the bundle | The payload needs methods, defaults, or nested objects rebuilt after a fetch |
| Zod schema | Parsing, an error naming the field, plus the inferred type | A dependency and a parse call per response | The JSON comes from an API you do not control |
The split worth internalising sits between the first three and Zod. An interface is a note for the compiler and nothing else. Write version: number, let the server send "4", and your build stays green while version + 1 produces the string "41" in production. Zod reads the same shape while the program runs, rejects the string, and names the field. Everything above the Zod row is documentation with autocomplete attached.
Interface against type alias
Both describe an object. The differences are narrow, and two of them decide most cases.
- Declaration merging. Two interfaces sharing a name combine into one. Two type aliases sharing a name are a compile error. Merging is what lets a consumer bolt a field onto a shape your library exports, which is a feature in a public API and a source of surprise inside an app.
- Unions and primitives. Only a type alias holds
Status = "open" | "closed"orId = string | number. An interface describes an object shape and nothing else, so a union of two payload shapes has to be an alias. - Composition. Interfaces use
extends, aliases use&. They read differently and behave nearly the same, with one exception worth remembering.extendsreports a conflicting property as an error at the declaration. An intersection quietly resolves the same conflict tonever, and the failure surfaces later at the assignment. - Error messages. Interfaces keep their name in compiler output. Aliases over object literals are often expanded into the full shape, which turns a two line error into forty.
Start with interfaces. Switch a shape to an alias when the shape stops being an object.
Your types are a promise nobody checks
This is the part missed most often, so here it is in code. Both snippets compile. One of them is a lie.
const res = await fetch("https://api.toolexe.com/workspace"
);const data: ApiWorkspace = await res.json();data.quota.daily.toFixed(0);const res = await fetch("https://api.toolexe.com/workspace"
);const data = ApiWorkspaceSchema.parse(await res.json());data.quota.daily.toFixed(0);res.json() is typed any in the standard library, and any assigns to anything. The annotation on the left tells the editor what to autocomplete and produces no code at all after compilation. When the API renames a field or starts sending null, the first sign is a TypeError three call frames away from the fetch.
Two habits keep the gap small. Type the fetch boundary as unknown so an assignment forces a decision, and reach for the Zod output on anything crossing a network you do not own. The unknown rather than any option in the rail follows the same principle for fields the sample leaves undefined, since unknown makes the compiler ask before the value is used.
What a JSON sample never tells you
Inference starts from values, and JSON carries eight types on a good day. These are the places where the generated type is a reasonable reading of the sample rather than the truth about the API.
| In the sample | Generated | What the API might mean |
|---|---|---|
"2026-08-14T09:21:05Z" | string | A timestamp. JSON has no date type, so parsing stays your job |
"GET" | string | Often one of five verbs, which wants "GET" | "POST" and a second sample to prove |
4 and 1200.5 | number | TypeScript has one number type, so an integer field and a decimal field look identical |
null | null | A field with a value the sample happened to miss |
[] | unknown[] | An array of records nobody created yet |
["a", 1] | (string | number)[] | A fixed pair, which wants the tuple [string, number] |
9007199254740993 | number | An identifier past the safe integer range, which belongs in a string |
{"eu-west": {...}} | Two oddly named fields | A lookup, which wants Record<string, Region> |
The identifier row bites hardest and shows up in production data. Numbers in JSON parse into IEEE 754 doubles, so an ID above 9007199254740991 comes back changed. Snowflake IDs from Discord and X, Twitter style cursors and some database sequences all live past the line. The API sending them as strings is doing you a favour, and the generated string is correct rather than lazy.
Paste the whole list, not one entry
Every item in an array is folded into a single shape before a type is written. A key present in all entries stays required. A key present in some of them comes through optional, and the decision log names the count. This is the reason to paste a full response rather than a trimmed record.
"endpoints": [{ "path": "/json/beautifier","method": "GET","cache-ttl": 300 },{ "path": "/json/json-to-typescript","method": "GET" }]export interface Endpoint {path: string;method: string;"cache-ttl"?: number;}Three decisions are visible there. The plural key endpoints was made singular for the type name, so the field reads Endpoint[] rather than Endpoints[]. The second entry has no cache-ttl, so the field is optional and the log says one of two records was missing it. And cache-ttl is quoted, because a dash is legal in a JSON key and illegal in a TypeScript identifier.
Keys JSON allows and TypeScript will not
A JSON key is any string. An identifier is not, and payloads break the rule constantly, mostly because kebab-case reads well in a config file nobody writes code against.
- Dashes, dots and spaces.
cache-ttl,log.levelandcontent typeare all quoted in the output. The type stays correct and access moves todata["cache-ttl"], since dot notation stops working. - Leading digits.
2fais a legal key and an illegal identifier, so the quoted form is the only form. - Unicode and emoji. Both survive as quoted keys. Whether the rest of your stack agrees is a separate question.
- Type names. Only the generated type names are cleaned up, never the keys. A block under
user-profilebecomesUserProfile, and the field reading it keeps the original spelling.
Renaming a key rather than quoting one would break the payload, so the generator never does. A camelCase codebase reading a snake_case API needs a mapping layer, and Zod gives you the natural place for one with .transform() after the parse.
What the class output builds for you
The three type styles vanish at build time. The class style produces code, and the constructor is the reason to pick it. Unpacking a response into a class you wrote by hand fills the top level and stops, so a nested field stays the plain object JSON.parse produced.
const w = Object.assign(new ApiWorkspace(), raw);w.owner instanceof Owner;const w = new ApiWorkspace(raw);w.owner instanceof Owner;w.endpoints[0] instanceof Endpoint;Each nested object gets its own class, the constructor calls new Owner(data.owner), and arrays of objects are mapped one entry at a time. Methods you add to Owner work on data straight off the wire. The cost is real: classes are values, so they ship in the bundle, and a shape used only for typing pays that weight for nothing.
Reading the decision log
The panel under the editors lists what the generator worked out and what the sample left open. Read it before pasting anything into a repository. Green markers are decisions made from solid evidence, such as a quoted key or two blocks sharing one type. Amber markers are gaps in your sample: an empty array, a null value, a field missing from some entries, a key holding more than one shape. Every amber line is a spot where a second response would produce a better type.
Where this converter stops
- String literal unions need a human. A
methodfield holding"GET"in three records might accept five verbs. Two samples cannot show the set, and guessing would be worse thanstring. - Nothing validates formats or ranges. An email of
bananaand a port of 99999 are typedstringandnumberwithout comment. The Zod output gives you somewhere to add.email()or.min(1).max(65535)afterwards. - Recursive shapes are expanded, not linked. A comment holding replies of the same shape produces nested types rather than a self reference. Two blocks with matching fields do share one type, which softens the duplication without solving it.
- Tuples read as arrays. A fixed pair of coordinates comes through as
number[], since nothing in JSON marks a fixed length. - Comments and trailing commas fail the parse. The reader is
JSON.parse, so JSON5 and JSONC files need cleaning first. - Large payloads slow the tab down. Parsing and generation both run on the main thread. Files in the low megabytes are fine. A hundred megabyte export is not, and a trimmed slice produces the same types anyway.
Nothing you paste leaves the page. Parsing, inference and code generation all run in your browser, so a response carrying a customer record or a bearer token stays on your machine. Load the page once, drop your connection, and everything above still works.
