ToolPal
TypeScript code on a dark screen with blue and orange syntax highlighting

Stop Writing TypeScript Interfaces by Hand: Use a JSON Generator Instead

πŸ“· Roman Synkevych / Pexels

Stop Writing TypeScript Interfaces by Hand: Use a JSON Generator Instead

Manually writing TypeScript interfaces from JSON is tedious and error-prone. Here's how to automate it and what to watch out for.

April 7, 202611 min read

If you've spent twenty minutes hand-writing TypeScript interfaces from a JSON payload you just got back from an API, you already know it's one of those tasks that feels important but is almost entirely mechanical. You're not thinking. You're just copying field names and inferring types. It's the kind of work a machine should be doing.

And yet, a lot of developers still do it by hand. This guide covers why that's worth stopping, what a JSON to TypeScript generator actually produces, and β€” more importantly β€” what it can't do so you know where to take over.

Why TypeScript Interfaces Actually Matter

Before getting into the tool, it's worth being precise about what you get from typed interfaces beyond "catching bugs."

IDE autocomplete is the underrated one. When your API response is typed, your editor knows exactly what fields exist and what shape nested objects have. You stop guessing whether it's user.profilePicture or user.profile_picture or user.avatar. TypeScript just tells you.

Refactoring becomes much safer. If a backend team renames a field from userId to user_id, every place in your codebase that references the old name breaks at compile time β€” not silently at runtime after a user hits an error in production.

Documentation that doesn't go stale. A well-named interface is often more useful than a written doc. interface PaginatedOrderResponse with its fields is self-explanatory. A Confluence page about the same thing is almost certainly out of date.

None of this is novel TypeScript advice. But it's easy to skip typing a response "just this once" when you're moving fast, and then your codebase is quietly accumulating any types.

The Manual Process vs. Using a Generator

Here's what writing types by hand looks like for a medium-complexity API response:

{
  "user": {
    "id": 1042,
    "email": "alice@example.com",
    "name": "Alice Nguyen",
    "roles": ["admin", "editor"],
    "profile": {
      "bio": "Frontend developer based in Berlin.",
      "avatarUrl": "https://cdn.example.com/avatars/1042.png",
      "joinedAt": "2023-06-15T08:30:00Z"
    },
    "settings": {
      "theme": "dark",
      "notifications": {
        "email": true,
        "push": false
      }
    }
  },
  "meta": {
    "requestId": "abc-123",
    "timestamp": 1712486400
  }
}

Writing this by hand means:

  1. Creating an interface for the root object
  2. Noticing user and meta are nested objects, creating interfaces for those
  3. Noticing profile and settings are nested within user, creating interfaces for those
  4. Noticing notifications is nested within settings, creating one more interface
  5. Deciding on names for all of them
  6. Checking every primitive: is that 1712486400 a number? Yes. Is joinedAt a string? Yes, because JSON doesn't have a Date type.

Paste that same JSON into the JSON to TypeScript tool and you get this in under a second:

export interface Root {
  user: User;
  meta: Meta;
}

export interface User {
  id: number;
  email: string;
  name: string;
  roles: string[];
  profile: Profile;
  settings: Settings;
}

export interface Profile {
  bio: string;
  avatarUrl: string;
  joinedAt: string;
}

export interface Settings {
  theme: string;
  notifications: Notifications;
}

export interface Notifications {
  email: boolean;
  push: boolean;
}

export interface Meta {
  requestId: string;
  timestamp: number;
}

That's the entire structure, properly nested, with arrays typed, primitives inferred correctly. All you need to do is rename Root to something meaningful like UserResponse and you're done.

For large API responses β€” the kind with 40+ fields and four levels of nesting β€” the time savings are significant. More importantly, you're less likely to make a mistake.

Real-World Use Cases

Typing API Responses

This is the primary use case. You're integrating with a third-party API β€” payment processor, CRM, weather service β€” and you need types for what comes back. Copy the sample response from their docs, paste it in, rename the root interface, done.

The one thing to watch: sample responses in docs sometimes omit fields that appear in real responses, or include fields that are optional in practice. Always cross-reference with the actual API spec when it exists.

Config Files

If your app reads a JSON config file at runtime, you want a matching TypeScript interface so that accessing config.database.host fails at compile time if you mistyped it. Paste your config JSON, generate the interface, import it wherever you load the config.

// config.interface.ts (generated from config.json)
export interface AppConfig {
  database: Database;
  redis: Redis;
  featureFlags: FeatureFlags;
}

export interface Database {
  host: string;
  port: number;
  name: string;
  ssl: boolean;
}

Schema Exploration for Database Responses

If you're using a query builder or raw SQL and logging the result, you can paste the logged row objects to quickly generate a rough type. It won't be perfect β€” the types won't know about nullable columns, for instance β€” but it gives you a starting point faster than reading through the schema manually.

Mocking and Test Data

When writing tests, you often need typed mock objects. Generate the interface from real data, then create your typed mock against it. TypeScript will tell you if your mock is missing a required field.

Handling the Tricky Cases

Auto-generation handles the common cases well. The tricky cases require your judgment.

Null Values

JSON allows null as a value, and generators will type those fields as null. But in practice, a null field usually means the field is optional β€” it might be a string sometimes and absent other times. A generator can't know this from a single sample.

If you see:

interface User {
  middleName: null;
}

What you probably want is:

interface User {
  middleName: string | null;
}

Or if it can truly be absent:

interface User {
  middleName?: string | null;
}

Arrays of Different Types

JSON arrays are supposed to be homogeneous, but JavaScript doesn't enforce this. If your sample has an array with mixed types β€” say [1, "two", true] β€” generators typically produce (number | string | boolean)[]. That's technically correct for the sample, but it's a signal that something unusual is happening in the data model. Worth investigating before just accepting the generated type.

Arrays with One Element

If your sample response happens to contain a single-item array, the generator can only infer from that one object. If the items in the array can have different optional fields in different objects, the generated interface will be incomplete. For arrays, it's worth testing with a larger sample if you can get one.

Dates

JSON has no native Date type. "2023-06-15T08:30:00Z" is a string as far as JSON is concerned, so the generator types it as string. If your application parses that string into a JavaScript Date object, you'll want to adjust the type in your interface. Some teams create a type alias:

type ISODateString = string;

This doesn't add runtime safety, but it's a useful documentation convention that signals "this string is expected to be an ISO 8601 date."

Discriminated Unions

Sometimes an API returns different shapes depending on a type or status field. For example:

{ "type": "success", "data": { "orderId": 123 } }
{ "type": "error", "code": "NOT_FOUND", "message": "Order not found" }

A generator can't detect this pattern β€” it'll just give you a merged interface with all possible fields, most of them optional. This is one case where you should write the types yourself:

type ApiResult = SuccessResult | ErrorResult;

interface SuccessResult {
  type: "success";
  data: OrderData;
}

interface ErrorResult {
  type: "error";
  code: string;
  message: string;
}

TypeScript's discriminated unions are excellent for this β€” the type field narrows the type automatically in conditional blocks.

Best Practices for the Types You Generate

Name the Root Interface Meaningfully

The generator will name it Root or something generic. Always rename it before committing. UserProfileResponse, CheckoutSessionPayload, ProductListItem β€” names that make the type findable and self-documenting.

Keep Types in Dedicated Files

A common pattern:

src/
  types/
    api/
      user.types.ts
      order.types.ts
      product.types.ts
    config.types.ts

This makes types importable from a predictable location and avoids scattering interface definitions throughout component files.

Consider Exporting from an Index File

If you have many type files:

// src/types/index.ts
export * from './api/user.types';
export * from './api/order.types';
export * from './config.types';

Then imports elsewhere stay clean: import { UserProfileResponse } from '@/types'.

When to Use interface vs. type

The generated output typically uses interface. That's generally the right call for object shapes β€” interfaces are extensible, they show better in TypeScript error messages, and they work well with extends. The main reason to switch to type is when you need union types or mapped types that interface syntax can't express.

Don't Over-nest

If a generated interface has a deeply nested sub-object that's only used in one place, it's fine to leave it nested. But if Address appears in UserProfile, OrderShipping, and InvoiceBillingInfo, extract it to a shared interface that the others reference.

Integration With Your Workflow

VS Code

TypeScript support in VS Code is excellent out of the box. Once your interfaces exist, hover-to-see-type, go-to-definition, and rename-symbol all work across the codebase. The main thing to get right is your tsconfig.json path aliases β€” if you use @/types imports, make sure paths is configured:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

Strict Mode

If you're starting a new project, enable "strict": true in tsconfig.json. This activates strictNullChecks, which forces you to handle null and undefined explicitly. It's stricter but it's also where a lot of the real value of TypeScript comes from. For existing projects, enabling strict mode incrementally is doable but requires fixing existing type errors.

Zod for Runtime Validation

TypeScript types are erased at runtime β€” they don't protect you from an API actually returning unexpected data. If you need runtime guarantees, consider Zod. You define a Zod schema and infer the TypeScript type from it:

import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  email: z.string().email(),
  name: z.string(),
});

type User = z.infer<typeof UserSchema>;

This is especially useful for third-party API integrations where you don't control the response shape. A JSON-to-TypeScript generator and a Zod schema generator serve different needs β€” the former is great for quickly typing known-good data, the latter adds runtime safety.

What the Generator Can't Do For You

To be direct about the limitations:

It doesn't know your business logic. A field typed as number might only ever be a positive integer in practice. The generator won't add that constraint β€” you'd need Zod or a similar validator for runtime enforcement.

It works from a snapshot. The generated types reflect one sample of data. Real APIs evolve. If the API adds a new optional field in a future release, your types won't know about it until you regenerate or update manually.

It can't distinguish required from optional. Every field in the sample is treated as required. But APIs routinely omit optional fields. You'll need to mark optional fields yourself with ? after reviewing the actual API contract.

It treats all strings as strings. A field like status that only ever contains "pending", "active", or "cancelled" will be typed as string rather than the more precise "pending" | "active" | "cancelled". Literal union types often provide better type narrowing in practice.

These aren't reasons to avoid the tool β€” they're reasons to treat the output as a draft, not a finished product. The generator handles 80% of the tedious work. You handle the 20% that requires understanding the actual semantics of the data.

Putting It Together

The workflow that makes sense for most projects:

  1. Get a real (or representative) sample of the JSON from the API, config, or other source
  2. Paste into the JSON to TypeScript generator
  3. Rename Root to something meaningful
  4. Copy the output into the appropriate *.types.ts file
  5. Review each field: mark optional fields with ?, add | null where needed, convert literal strings to union types where appropriate
  6. Use the JSON formatter if your source JSON is minified and hard to read, then generate types
  7. If you're also maintaining a YAML config, the JSON to YAML converter can help keep those in sync

The goal is to have accurate, named, organized TypeScript types without spending time on pure mechanical transcription. The generator does the mechanical part. You bring the context about what the data actually means.

Manually written types are not inherently better β€” they're just slower and more likely to have typos. Use the tool, review the output, and spend your time on the parts that actually require a human.

Frequently Asked Questions

Share this article

XLinkedIn

Related Posts