Turning a JSON API Response Into a TypeScript Interface

Hand-typing an API contract from a sample is slow and error-prone. Generating it is fast — provided you understand what the generator can and cannot infer.

Turning a JSON API Response Into a TypeScript Interface
Share this article:

You have a JSON response and you need TypeScript types for it. Writing them by hand means transcribing every field name exactly, guessing which are nullable, and repeating the exercise for each nested object. It is tedious, and a single typo produces a type that compiles and is wrong.

Generating them takes a second. The important part is knowing what the generator can infer and what it cannot.

What gets inferred

Paste a response into the JSON to TypeScript converter and each value maps to a type:

JSONTypeScript
`42``number`
`"Ada"``string`
`true``boolean`
`["a", "b"]``string[]`
`[1, "a"]``(number \string)[]`
`[]``unknown[]`
`{ "city": "London" }`a separate interface
`null``null` — see below

Nested objects become their own exported interfaces, declared before the interface that references them so the output compiles as written.

Optional fields need more than one record

This is where most generators get it wrong, and where the value actually is.

Consider an array where one element is missing a field:

{
  "posts": [
    { "id": 1, "title": "First", "views": 120 },
    { "id": 2, "title": "Second" }
  ]
}

A generator that types the array from its first element produces `views: number` — required. Your code then does `post.views.toFixed()` and crashes at runtime on the second record, which is precisely the failure TypeScript was supposed to prevent.

Merging every element gives the correct answer:

export interface Post {
  id: number;
  title: string;
  views?: number;
}

The practical consequence: paste a response with many records, not one. A single object provides no evidence that anything is optional, so everything comes out required. A response with twenty records produces a far more honest contract.

Why a null field comes out as null

If a field is `null` in every sample, the generated type is `null`. That looks unhelpful, and it is — but it is also correct. Null carries no type information. There is no way to know whether `bio: null` would otherwise hold a string, a number, or an object.

The fix is a better sample. Find a record where the field is populated, or write the union yourself:

bio?: string | null;

Any generator that guesses `string | null` here is inventing information it does not have.

Types are not validation

This is the most important thing to understand about generated types, and the most commonly missed.

TypeScript types vanish at compile time. They are erased entirely from the JavaScript that runs. If the API returns something different from your interface, nothing throws — you simply get `undefined` where you expected a value, usually several function calls away from the cause.

Types protect you while you write code. They do nothing at the boundary where untrusted data enters.

For that you need a runtime validator:

import { z } from 'zod'

const Post = z.object({
  id: z.number(),
  title: z.string(),
  views: z.number().optional(),
})

const data = Post.parse(await res.json())  // throws on mismatch

Generated interfaces are an excellent starting point for writing that schema. They are not a substitute for it.

Two things to check in generated output

Large numbers. JSON numbers are IEEE doubles. An ID above 2^53 loses precision in JavaScript no matter what the type says — which is why many APIs send large IDs as strings. If you see an ID typed `number` and it is longer than about 15 digits, check the raw response.

Date strings. An ISO date arrives as a string and is typed `string`. That is accurate. If you intend to work with `Date` objects, the conversion and its error handling are yours to write.

Common mistakes

  • Generating from one record. Produces types where nothing is optional. Covered above, and worth repeating because it is the single biggest cause of wrong output.
  • Committing `Root` as the interface name. Rename it to something meaningful before it spreads through the codebase.
  • Treating the output as final. It is a first draft. Narrow string fields to unions where a set of values is fixed, and split large interfaces that mix concerns.
  • Generating from a sample rather than a spec. If the API publishes an OpenAPI document, generate from that. It is a declared contract; a sample is one observation of it.
  • Forgetting error responses. The error shape is usually different from the success shape and needs its own type.

Frequently asked questions

Does the generator handle deeply nested objects?

Yes, each level becomes its own interface, named after the key holding it and emitted before its parent.

What about arrays of mixed types?

They produce a union, such as `(number | string)[]`. Worth investigating rather than accepting — mixed arrays often indicate an API design problem.

Can it generate enums?

No. A string field is typed `string`, because one sample cannot reveal the full set of permitted values. Narrow it by hand where you know the set.

Is my API response uploaded?

No, generation runs in your browser. Real responses often contain customer data or tokens, so that matters.

What if the JSON is a Python dict?

Convert it first with the [Python dict converter](/python-dict-converter) — single quotes and `True`/`None` are not valid JSON.

Related reading

For runtime safety rather than compile-time-only types, the JSON Schema generator produces draft-07 schemas a validator can enforce. If your records live in a log or dataset, the JSONL converter turns them into an array so you can generate from many records at once.

Generate TypeScript from JSON →