jsonpost
TypeScriptTypesCode Generation

From JSON to TypeScript Types: A Complete Guide

Turn JSON API responses into accurate TypeScript types. Learn interfaces vs type aliases, nested data, optional and nullable fields, unions, and validation.

JSONPost··6 min read
JSON to TypeScript Types

Most TypeScript bugs at the network boundary come from the same mistake: trusting that a JSON response matches what you expect. Typing those responses turns silent runtime surprises into compile-time errors. This guide walks through generating accurate TypeScript types from JSON, the tradeoffs you will hit, and how to keep the types honest at runtime.

Why typing API responses matters

When you fetch data, the value you get back is untyped. A common pattern looks like this:

const res = await fetch("/api/user");
const data = await res.json(); // data is `any`
console.log(data.fistName); // typo, no error, undefined at runtime

res.json() returns any (or Promise<any>). That any spreads through your code and disables every check TypeScript would otherwise give you. A typo like fistName compiles fine and fails silently in production. Giving the response a concrete type restores autocomplete, catches typos, and documents the shape of your data in one place.

Interfaces vs type aliases

Given this JSON:

{
  "id": 42,
  "firstName": "Ada",
  "isActive": true
}

You can describe it with an interface or a type alias. Both work here:

interface User {
  id: number;
  firstName: string;
  isActive: boolean;
}

type UserAlias = {
  id: number;
  firstName: string;
  isActive: boolean;
};

Use interfaces for object shapes you expect to extend or that other code implements; they merge declarations and produce cleaner error messages. Use type aliases when you need unions, intersections, mapped types, or tuples, which interfaces cannot express. For plain API payloads either is fine — pick one and stay consistent across the codebase.

Handling nested objects and arrays

Real responses nest. Consider an order with a customer and line items:

{
  "orderId": "ord_001",
  "customer": {
    "id": 7,
    "email": "ada@example.com"
  },
  "items": [
    { "sku": "A1", "qty": 2, "price": 9.99 },
    { "sku": "B2", "qty": 1, "price": 19.5 }
  ]
}

Model each nested object as its own named type rather than inlining everything. Named types are reusable and produce readable errors:

interface Customer {
  id: number;
  email: string;
}

interface LineItem {
  sku: string;
  qty: number;
  price: number;
}

interface Order {
  orderId: string;
  customer: Customer;
  items: LineItem[];
}

For arrays you have two equivalent syntaxes: LineItem[] and Array<LineItem>. The bracket form reads better in most cases; the generic form is handy when the element type is itself complex. For a list endpoint that returns a bare array, the top-level type is simply Order[].

Optional and nullable fields

JSON has two distinct ways a field can be "missing," and they map to different TypeScript constructs. A field that may be absent from the object is optional (?). A field that is present but may hold null is nullable (| null).

{
  "id": 1,
  "nickname": null
}
interface Profile {
  id: number;
  nickname: string | null; // present, but can be null
  bio?: string;            // may be absent entirely
  avatarUrl?: string | null; // may be absent OR null
}

This distinction matters. If you type a sometimes-missing field as string instead of string | undefined, TypeScript will let you call .toUpperCase() on a value that is actually undefined at runtime. Be precise: ? for absence, | null for explicit null, and combine both when the API does both.

Unions for variant shapes

Some endpoints return different shapes depending on a status or type field. A discriminated union models this safely:

{ "type": "success", "data": { "token": "abc" } }
{ "type": "error", "message": "Invalid credentials" }
type AuthResult =
  | { type: "success"; data: { token: string } }
  | { type: "error"; message: string };

function handle(r: AuthResult) {
  if (r.type === "success") {
    return r.data.token; // narrowed to the success variant
  }
  return r.message; // narrowed to the error variant
}

The shared literal field (type) is the discriminant. Checking it lets TypeScript narrow the union to one branch, so you can only access fields that actually exist on that branch.

unknown vs any

When you genuinely do not know a value's shape — a third-party webhook, an arbitrary metadata blob — reach for unknown, never any. Both accept any value, but unknown forces you to check before using it, while any disables all checking and quietly infects everything it touches.

function parse(input: unknown) {
  // input.foo;          // error: object is of type 'unknown'
  if (typeof input === "object" && input !== null && "foo" in input) {
    // safe to inspect now
  }
}

Treat any as a code smell and unknown as the honest default for untyped data.

Runtime validation

Here is the catch every TypeScript developer eventually learns: types are erased at compile time. They describe what you expect, but they do not check the data you actually receive. If the API changes a field from a number to a string, your typed code compiles and still breaks at runtime.

To close that gap, validate the payload against a schema before trusting it. You can generate a schema from a sample with the JSON Schema Generator and check responses with the JSON Schema Validator. In code, libraries like Zod let your runtime check and your static type stay in sync:

import { z } from "zod";

const User = z.object({
  id: z.number(),
  firstName: z.string(),
  isActive: z.boolean(),
});

type User = z.infer<typeof User>; // type derived from the schema

const data = User.parse(await res.json()); // throws if the shape is wrong

Now the static type and the runtime guard come from a single source of truth.

Generating types automatically

Writing these types by hand is tedious and error-prone for large payloads. Paste a representative response into the JSON to TypeScript tool and it infers interfaces, nested types, arrays, and optional fields for you in seconds. A couple of tips for clean output:

  • Feed it a complete example. Fields missing from your sample cannot be inferred, and a null value alone tells the generator nothing about the real type, so prefer a populated record.
  • Merge several samples mentally for fields that vary, then mark them optional or union them after generating.
  • Rename the generated root type to something meaningful and move shared nested types into their own declarations if you reuse them.

The generator gets you 90 percent of the way; you apply judgment for optionality, nullability, and unions that a single example cannot reveal.

Conclusion

Typing your JSON gives you autocomplete, refactoring safety, and living documentation — but only if the types are accurate. Model nested data as named types, distinguish optional from nullable, use discriminated unions for variants, prefer unknown over any, and back it all with runtime validation so the types do not lie. Start by dropping a real response into the JSON to TypeScript tool, then refine the output and pair it with a schema check. Explore the rest of the tools on JSONPost to round out your JSON workflow.

Keep reading