All posts

JSON to TypeScript: The Edge Cases Automation Misses

6 min read

Converting JSON to TypeScript interfaces is straightforward until you hit nullable fields, deeply nested objects, or arrays of unions. Here's how to handle the cases that automated tools get wrong.

Automated JSON-to-TypeScript conversion works well until it meets real production data. Paste a JSON response into a type generator and it works great for the happy path. Then you get a response where a field is sometimes null, sometimes a string, and sometimes missing entirely. The generated type says string and your IDE doesn't catch the bug until runtime.

Nullable Fields Are Never Just Null

A converter sees this:

{
  "user": {
    "name": "Alice",
    "avatar": null,
    "bio": "Full-stack developer"
  }
}

And generates avatar: null. The field can only be null, according to the type. In practice it can be a string too. TypeScript will error if you try to assign a string to avatar, so you either start casting or adding null checks everywhere.

The type you actually want is string | null. Whether a converter produces that depends on how it inspects the JSON. Some look at a single sample and treat null as the only possible value. The JSON to TypeScript tool handles this correctly in most cases, but I've learned to check nullable fields manually regardless of the generator. In my experience, they're the first thing worth checking because they tend to be responsible for a surprising number of runtime issues.

Some APIs make this even worse by using null and missing fields interchangeably. An endpoint might return "avatar": null when the user hasn't set one, but omit the field entirely when avatars aren't supported. Once both conventions appear in the same response, no generator can infer the correct type from a single sample. OpenAPI generators avoid part of this since the schema usually defines the types. The downside is that your types depend on the schema staying accurate, and that's not always a safe bet.

What should have been a simple string | null check turns into a mess because different people solve it differently. One writes a helper, another adds a conditional, a third changes the type.

Too Many Interfaces

What converters do to nested JSON is harder to undo than a nullable field. They generate a separate interface for every level of nesting, and suddenly you're maintaining twenty type definitions for what should be a straightforward response.

{
  "order": {
    "id": 123,
    "customer": {
      "name": "Alice",
      "address": {
        "street": "123 Main St",
        "city": "Portland",
        "zip": "97201"
      }
    },
    "items": [
      {
        "product": {
          "name": "Widget",
          "category": {
            "id": 5,
            "name": "Gadgets"
          }
        },
        "quantity": 2
      }
    ]
  }
}

A converter gives you Order, Customer, Address, Item, Product, Category. Six interfaces for one endpoint. Each one shows up in autocomplete and adds friction when you're searching for the type you actually need.

If the shape only appears once, inline it. If it starts repeating across endpoints, that's when I extract an interface. Keeping every generated interface because "you might need it later" is a trap. Six months later they're still there, untouched, making the type explorer harder to navigate.

Arrays Are Where Runtime Bugs Start

Arrays inside nested objects introduce a problem that doesn't surface until runtime. An empty array [] and a missing field "items": null are different things. One means "no items exist." The other means "items weren't requested."

Many converters collapse both into items: Item[]. The code compiles. Then "Cannot read properties of null" appears in your error tracker.

The more accurate types are:

// Always present, might be empty
items: Item[];

// Sometimes missing
items?: Item[] | null;

Which one you need depends on the API. If the field is always present, use Item[]. If it sometimes disappears, use the union. I'd trust the API responses over the docs every time. Documentation is often one deployment behind and doesn't always reflect what the server actually sends.

The same pattern repeats across every field, not just arrays:

What the API sends What generators emit What you need
"avatar": null avatar: null avatar: string | null
"items": [] items: Item[] items: Item[]
"items": null items: Item[] items?: Item[] | null
field missing entirely field: Type field?: Type

Same symptom, three different contracts. A generator working from one sample can't tell them apart, so the review has to.

Discriminated Unions Beat Generated Types

Some APIs return different shapes based on a type field:

{ "type": "text", "content": "Hello", "formatting": "markdown" }
{ "type": "image", "url": "https://example.com/photo.png", "width": 800, "height": 600 }

Depending on the generator, you'll usually end up with every field merged into one interface with all properties optional. Others generate unrelated interfaces with no relationship at all. Neither approach captures the actual contract.

A discriminated union gives you proper narrowing. When you check block.type === "text", TypeScript knows content is available (the TypeScript handbook covers the mechanics):

interface TextBlock {
  type: "text";
  content: string;
  formatting: string;
}

interface ImageBlock {
  type: "image";
  url: string;
  width: number;
  height: number;
}

type Block = TextBlock | ImageBlock;

A lot of teams end up writing these by hand. Unless the generator has enough information, it often can't infer the relationship between the discriminator and the shape that follows. It needs multiple samples to understand which fields belong to which variant. The JSON to TypeScript converter supports this if you provide samples of each variant, but I'd still check the output.

Pick One Optional Style

Default to ?. JSON represents missing data as absent keys, and ? is the only syntax that lets you omit the field at the assignment site. | undefined forces every call site to write the key with an explicit undefined value, which is friction your code doesn't need.

meta?: Record<string, unknown>;
// vs
meta: Record<string, unknown> | undefined;

They're similar but behave differently in one meaningful way. With ? you can leave the field out entirely. With | undefined the key must always appear, with undefined as its value. Converters tend to generate the ? version because it matches how JSON works. Absent keys are the norm there.

| undefined earns its place when you want to force the key to always exist, for example in object literals that get validated structurally. Outside that case it's bikeshedding: half the team prefers one style, the other half the other, and the debate costs more than either choice. Mixing both causes confusion in reviews and subtle bugs in tests that construct response objects.

If you're reviewing generated types before merging, start with nullable fields. They tend to expose the rest of the problems surprisingly quickly.

OCMA Tools

Free developer tools. Most features run client-side, your data stays in your browser. Optional accounts unlock extra features.

Most tools run client-side

© 2026 OCMA Tools — Free developer tools

built for developers, by developers