712Tools
5 min read

JSON to TypeScript: workflows that scale beyond a single sample

A one-shot JSON โ†’ interface tool is great for demos and dead for production. Here are the patterns real teams use to keep types in sync with real APIs.

The one-shot generator gets you 80% of the way

Paste a JSON sample, get an interface. That works for:

  • Sketching types from a sample API response you've received
  • Turning a config file into a typed config
  • Bootstrapping types for a new integration

It stops working when the API has multiple response shapes, optional fields that aren't in your sample, or nested objects where the same shape appears in different places.

The three "same JSON, different types" problems

1. Missing optionals. Your sample has { id: 1, name: "Ada" }; the API sometimes returns { id: 2, name: "Bob", email: "b@b" }. A one-shot generator produces the narrower type and TypeScript now lies about the API.

Fix: generate from multiple samples and mark differing fields optional. Or hand-edit the generated interface to add email?: string โ€” often the fastest path.

2. Sum types (discriminated unions). Your API returns { type: "success", data: {...} } on happy path and { type: "error", message: "..." } on failure. A one-shot generator sees them as one merged type with all fields optional. That's the wrong shape.

Fix: generate each variant separately, then hand-combine into a discriminated union:

type Response =
  | { type: "success"; data: Data }
  | { type: "error"; message: string };

3. Recursive types. { id: 1, children: [{ id: 2, children: [...] }] }. Generators emit inline nested types up to a depth, then give up. Rewrite as a named recursive type:

interface Tree { id: number; children: Tree[]; }

When to use runtime validation instead

TypeScript types are erased at compile time. At runtime the API can return literally anything. If the types are load-bearing (feed into a database write, expose to users, drive UI branching), you need runtime validation.

Two popular patterns:

Zod. Write the schema once, infer the type, validate at runtime:

import { z } from "zod";

const User = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email().optional(),
});
type User = z.infer<typeof User>;

const result = User.safeParse(apiResponse);
if (!result.success) throw new Error(result.error.message);

JSON Schema + json-schema-to-typescript. OpenAPI already gives you JSON schemas for every endpoint; generate TS types from those. The pipeline is more setup but keeps a single source of truth.

When to skip TypeScript types entirely

If the JSON is truly unstructured (a JSONB blob, a webhook body you don't own), typing it is theatre. Use unknown and validate the fields you actually access:

function getName(response: unknown): string {
  if (typeof response !== "object" || response === null) return "unknown";
  const r = response as Record<string, unknown>;
  return typeof r.name === "string" ? r.name : "unknown";
}

The workflow for adding a new API endpoint

The pattern that works on real teams:

  1. Capture a real response. Not a mock, not the docs โ€” hit the actual endpoint.
  2. Format it so you can see the shape at a glance.
  3. Generate an initial interface with JSON โ†’ TypeScript.
  4. Hand-edit to mark optionals, add unions, name nested types semantically.
  5. Cross-check with a second sample โ€” call the endpoint a few more times, with different inputs, and diff the responses against the first to find fields that vary.
  6. Add runtime validation with Zod or a JSON schema, if the response drives anything critical.

The generator gives you a starting shape in ten seconds. Steps 4-6 give you types you can trust.

Interface or type alias?

interface User { id: number; name: string; }
type User = { id: number; name: string; };

At runtime โ€” identical. In practice:

  • interface wins for object shapes that other code will extends from. Better error messages in the IDE.
  • type wins for unions, intersections, mapped types, and literal types.

Both work. Pick one, use consistently in a codebase.

Keeping types in sync with a moving API

If you own both sides, generate types from the schema, not the samples. If you don't own the API:

  • Snapshot the response weekly in CI and diff against last week's. If the shape changes, alert.
  • Validate every response at runtime so a silent shape change fails loud in staging, not in production.

Related tools:

Tools mentioned in this post