MakeMyStats
Blog
Back to blog

Generating TypeScript types from JSON — a practical guide

How to infer TypeScript interfaces from JSON data, when structural inference works well, and where it breaks down.

Try /json-to-ts

Generating TypeScript types from JSON — a practical guide

You have a JSON response from an API. You need TypeScript types for it. You could write them by hand, staring at the JSON and translating each key into a typed field. Or you could let a tool infer the types from a sample and give you a starting point in seconds.

Inferring types from JSON is one of those tasks that sounds trivial until you actually try to do it well. The happy path — flat objects with consistent fields — takes about ten lines of code. Everything else is edge cases.

When type inference from JSON is useful

The most common scenario: you're integrating with a third-party API that has poor or no TypeScript definitions. You have example responses. You need types so your editor stops yelling at you and your code stops silently breaking when the API changes shape.

Other good use cases:

  • Prototyping against a mock API. You have a JSON fixture, you want types now, and you'll refine them later once the real API stabilizes.
  • Migrating JavaScript to TypeScript. You have JSON.parse() calls everywhere returning any. Generating types from actual runtime data gets you to a typed codebase faster than writing every interface from scratch.
  • Documenting data shapes. Sometimes the type definition is the documentation. Generating it from a sample payload creates an accurate snapshot of what the data actually looks like, not what someone intended it to look like six months ago.

The key word in all of these is starting point. Inferred types are a first draft. They capture the structure of a specific sample, not the full contract of the data source. More on that in a moment.

How structural inference works

The algorithm walks the JSON value recursively. For each node, it determines the TypeScript type:

  • Primitives map directly: "hello"string, 42number, trueboolean, nullnull.
  • Objects become interfaces (or type aliases) with a field for each key. The type of each field is inferred recursively.
  • Arrays inspect every element and merge the inferred types into a single element type. An array of [1, 2, 3] gives number[]. An array of [1, "two", 3] gives (number | string)[].

The interesting decisions happen at the edges.

Arrays of objects

This is the most valuable case — and the trickiest. Given:

[
  { "id": 1, "name": "Alice", "email": "[email protected]" },
  { "id": 2, "name": "Bob", "email": null }
]

A naive approach infers the type from the first element and calls it done. A better approach scans every element, merges their fields, and marks fields as optional when they're missing from some elements. In this example, email appears in both objects but is null in the second — so the inferred type should be string | null, not just string.

If the second object were { "id": 2, "name": "Bob" } with email missing entirely, then email should be email?: string — optional, not nullable. That distinction matters: optional means the key might not exist, nullable means the key exists but the value is null. In practice, many APIs are sloppy about this difference, but your types shouldn't be.

Naming nested types

Given a nested object like:

{
  "user": {
    "name": "Alice",
    "address": {
      "street": "123 Main",
      "city": "Springfield"
    }
  }
}

You have two choices for the output:

Inline nested types — everything is defined in one block:

interface Root {
  user: {
    name: string;
    address: {
      street: string;
      city: string;
    };
  };
}

Named subtypes — nested objects get their own interfaces:

interface Address {
  street: string;
  city: string;
}

interface User {
  name: string;
  address: Address;
}

interface Root {
  user: User;
}

Inline is more compact and easier to scan for small payloads. Named subtypes are better for larger structures because you can reference Address independently, and the type names serve as documentation. Neither is universally right — it depends on how deeply nested the data is and whether you'll reuse the subtypes elsewhere.

Where inference breaks down

Structural inference from a sample has fundamental limitations. Knowing these upfront saves you from trusting inferred types too much.

A sample is not a schema. If your sample payload happens to have "status": "active", inference will give you status: string. It can't know that the actual type is "active" | "inactive" | "pending". Enum values, string literal unions, and constrained types all look like plain string or number from a single sample.

Empty arrays are opaque. An empty [] has no elements to infer from. The best the tool can do is unknown[] or any[]. If you see this in the output, you need to fill in the element type manually.

Polymorphic fields are tricky. If a field is 42 in one response and "forty-two" in another, inference merges them into number | string. But if you only have one sample, you'll get number and miss the string case entirely. The inferred type is only as complete as the samples you feed it.

Structural similarity doesn't mean semantic equivalence. Two objects with the same shape might represent different things. Inference can't know that { id: number, name: string } should be User in one context and Product in another. You'll need to rename the generated types to reflect their actual meaning.

Walkthrough with the JSON to TypeScript tool

MakeMyStats's JSON to TypeScript converter runs entirely in your browser. Paste or drop JSON on the left, get TypeScript on the right.

Start with a sample API response. Paste it into the left pane:

{
  "id": 1,
  "username": "alice",
  "email": "[email protected]",
  "profile": {
    "bio": "Software engineer",
    "avatar_url": "https://example.com/alice.png",
    "social_links": [
      { "platform": "github", "url": "https://github.com/alice" },
      { "platform": "twitter", "url": null }
    ]
  },
  "created_at": "2024-01-15T10:30:00Z",
  "is_active": true
}

With default settings, you get an interface with inline nested types. Toggle "Named subtypes" and Profile and SocialLink get extracted as separate interfaces — much easier to work with if you're passing those subtypes around independently.

Turn on "Readonly" if the data is read-only in your code (API responses usually are). This adds readonly to every field, which helps catch accidental mutations.

The "Optional from null" toggle controls what happens with that null URL in the social links. With it on, the tool generates url?: string | null. With it off, you get url: string | null — still nullable, but required.

Set the root type name to something meaningful. Root is a placeholder. For an API response, UserResponse or GetUserResult is more useful.

Refining the output

The generated types are a starting point. Here's a practical checklist for turning them into production-quality types:

  1. Rename generics. Change Root to UserResponse, RootItem to User, etc. The tool derives names from structure, but semantics come from you.

  2. Tighten string literals. Replace platform: string with platform: "github" | "twitter" | "linkedin" if you know the valid values. Inference can't discover constraints it hasn't seen.

  3. Fix empty arrays. Replace unknown[] with the actual element type. Check the API docs or another sample response.

  4. Add JSDoc comments to fields whose purpose isn't obvious from the name. created_at is self-explanatory; tier probably isn't.

  5. Consider Date vs string. JSON has no date type — dates arrive as strings. If you parse them into Date objects, change the type. If you keep them as ISO strings, string is correct. Don't use Date in the type if your code never actually parses the string.

  6. Decide on interface vs type. Interfaces can be extended and merged; type aliases can use unions and intersections. For API response shapes, interfaces are conventional. For unions or computed types, type is the right choice.

The line between inference and design

Type inference from JSON bridges the gap between "I have data" and "I have types." It eliminates the tedious transcription step and gives you a structural starting point in seconds rather than minutes.

But the resulting types are descriptive, not prescriptive. They describe what one sample looked like, not what all valid data must look like. The real work — naming, constraining, documenting — still falls on you. A generated string might need to become a literal union. A generated optional field might actually be required in all non-error responses. A generated number might need to be branded as UserId.

Use inference to skip the boilerplate. Then apply your knowledge of the domain to turn those inferred types into types worth keeping. Try it with your own data — paste a response, generate the types, and see how close the first draft gets.