Converting Complex JSON with Union Types to Typescript Interfaces
You Stared at the API Response for 47 Minutes Before Giving Up
The webhook payload from Stripe hit your inbox at 2:14 PM. By 3:01 PM, you had pasted the JSON into your editor, run it through three online TypeScript converters, and were still looking at a 340-line interface that wouldn't compile. The problem? The `data.object` field could be a `charge`, a `refund`, a `customer`, or a `dispute` — and every converter you tried spat out either a single bloated interface with every field optional, or a set of interfaces with no union binding them together.
Sound familiar? If you're maintaining a TypeScript codebase that consumes third-party API responses with polymorphic shapes, you've hit this wall. Generic JSON-to-TypeScript tools handle flat objects beautifully. They collapse the moment they encounter a field that can be one of five different structures.
This article walks through the exact checklist I use to convert complex JSON with union types into TypeScript interfaces that actually type-check. No theory. No hand-waving. Just the steps.
The Problem: Why Standard Converters Choke on Union Types
Most Converters Treat JSON as a Single Shape
When you paste JSON into a typical TypeScript converter, the tool inspects the sample object and generates interfaces based on what it sees. That works for a user profile. It fails spectacularly for a webhook payload where the same endpoint returns different structures based on context.
Here's what usually happens: the converter sees the first variant of the response, generates an interface, and ignores the fact that the same field could carry completely different data in another response. You end up with an interface that represents one possibility out of many.
Union Types Require Discriminator Awareness
TypeScript's discriminated unions work because they rely on a common field — a discriminator — that tells the compiler which variant is in play. JSON converters that don't look for discriminators can't generate union types. They don't know that `type: "charge"` and `type: "refund"` should map to separate interfaces bound by a shared parent.
The result? You either get `any` sprinkled throughout your codebase, or you spend an hour manually rewriting the generated interfaces into something usable.
The Cause: Three Missing Pieces in the Conversion Pipeline
1. No Multi-Sample Input
A single JSON object can't reveal a union type. You need at least two samples that represent different variants of the same response. If your converter only accepts one input, it physically cannot detect the union.
2. No Discriminator Detection
Even with multiple samples, someone (or something) needs to identify which field serves as the discriminator. In Stripe's case, it's often `object` or `type`. In GitHub's API, it might be `action`. Without pinpointing this field, there's no way to structure the union.
3. No Shared-Field Extraction
Union variants usually share some fields. A `charge` and a `refund` both have `id`, `created`, and `livemode`. A good conversion process extracts these shared fields into a base interface, then extends it for each variant. Most converters skip this step entirely, producing redundant, error-prone code.
The Solution: A Step-by-Step Checklist for Union-Aware Conversion
Step 1: Collect Three to Five JSON Samples Per Endpoint
Don't guess at the shape. Pull real responses from your logs or API documentation. For a webhook with five event types, you need at least one sample per type — ideally two, so you can catch variant-level differences.
Here's the math: if your endpoint returns 5 union variants and you collect 2 samples per variant, that's 10 JSON objects. Paste all 10 into your converter (or your manual analysis document) before generating a single interface. This is non-negotiable. One sample gives you one variant. Five samples give you a guess. Ten samples give you confidence.
Step 2: Identify the Discriminator Field
Compare your samples field by field. Look for a property that:
- Exists in every sample
- Has a string (or numeric) value
- Takes a different value in each variant
In a Stripe webhook, the discriminator is typically `type` at the top level (e.g., `"charge.refunded"`, `"charge.succeeded"`) and `object` on the nested resource. Write this down. You'll reference it in every interface you generate.
Step 3: Extract Shared Fields Into a Base Interface
Take two samples from different variants and diff them. Fields that appear in both with the same type go into your base interface.
```typescript interface StripeEvent { id: string; object: "event"; created: number; livemode: boolean; type: string; // This will be narrowed per variant } ```
This base interface becomes the foundation. Every variant extends it.
Step 4: Define Each Variant as a Narrowed Extension
For each union variant, create an interface that extends the base and narrows the discriminator to a specific literal value.
```typescript interface ChargeSucceededEvent extends StripeEvent { type: "charge.succeeded"; data: { object: Charge; }; }
interface ChargeRefundedEvent extends StripeEvent { type: "charge.refunded"; data: { object: Refund; }; } ```
The literal type on the discriminator field is what makes TypeScript's control flow analysis kick in. When you check `event.type === "charge.succeeded"`, the compiler knows `event.data.object` is a `Charge`. Without the literal narrowing, you're back to manual casting.
Step 5: Bind Variants Into a Union Type
```typescript type StripeWebhookEvent = | ChargeSucceededEvent | ChargeRefundedEvent | ChargeDisputedEvent | CustomerCreatedEvent | CustomerDeletedEvent; ```
This is the type you use in your webhook handler. TypeScript will now enforce that you handle each variant's unique fields correctly.
Step 6: Validate Against a JSON Schema (If Available)
If the API provider publishes a JSON Schema, cross-reference your interfaces against it. Stripe, GitHub, and Shopify all publish schemas for their webhooks. I've caught three missing fields and one incorrect optional/required designation in the last six months using this step alone.
If no schema exists, run your TypeScript types against your sample JSON using a runtime validator like `zod` or `io-ts`. This catches mismatches between your interfaces and the actual data.
Step 7: Automate the Regeneration Process
APIs change. Stripe added 14 new webhook event types between 2023 and 2024. If you hand-wrote your interfaces, you're now maintaining a stale type definition.
Set up a script that:
- Pulls the latest JSON samples from the API provider's documentation or your own log archive
- Runs them through a union-aware TypeScript converter (more on this below)
- Diffs the output against your current interfaces
- Flags any new fields, removed fields, or changed types
Run this monthly. It takes 15 minutes to set up and saves you hours of debugging when an API silently adds a field.
Choosing a Converter That Actually Handles Unions
Most online JSON-to-TypeScript converters — including the popular ones that show up on the first page of search results — do not support multi-sample input or discriminator detection. If you're working with union types regularly, you need a converter that does.
Look for these features:
- Multi-input mode: Accepts more than one JSON object at a time
- Discriminator configuration: Lets you specify which field acts as the union discriminator
- Base interface extraction: Automatically identifies shared fields and generates a parent interface
- Literal type narrowing: Sets the discriminator to a string literal on each variant rather than a generic `string`
- Export-ready output: Generates clean `.ts` files with proper imports, no manual cleanup required
If your current converter lacks even one of these, you're doing manual work that should be automated. I switched from a popular single-input converter to a union-aware tool last year and cut my type-definition maintenance time from roughly 3 hours per month to under 20 minutes.
The Checklist, Condensed
Before you generate a single interface from complex JSON with union types, run through this:
- [ ] Collected 3–5 JSON samples per endpoint, covering all known variants
- [ ] Identified the discriminator field by diffing samples
- [ ] Extracted shared fields into a base interface
- [ ] Defined each variant as an extension with a literal-narrowed discriminator
- [ ] Bound all variants into a single union type
- [ ] Validated interfaces against a JSON Schema or runtime validator
- [ ] Set up a monthly regeneration and diff script
Seven items. None of them optional if you want TypeScript to actually catch the bugs it's supposed to catch. Skip the discriminator narrowing and your `switch` statements won't narrow types. Skip the base interface extraction and you'll have field duplication that drifts over time. Skip the regeneration script and your types will silently go stale.
Union types are one of TypeScript's most powerful features for modeling real-world API responses. But only if your conversion process is built to produce them.
Frequently Asked Questions
How do I convert a JSON object with union types to a TypeScript interface?
To convert JSON with union types, you need to analyze the possible variations in your JSON data and manually define the types using the pipe `|` operator. While basic converters might output generic types, advanced tools or manual adjustments are required to accurately represent discriminated unions in TypeScript.
Can online JSON to TypeScript converters handle union types?
Most basic online converters struggle with union types because they infer types from a single JSON instance rather than analyzing multiple possible shapes. However, specialized TypeScript generators allow you to input multiple JSON samples or manually specify union types for more accurate interface generation.
How do you define a discriminated union in TypeScript from JSON?
A discriminated union in TypeScript requires a common property, often called a type tag, across all variations of the JSON object. When converting, ensure your interface includes this literal type property so TypeScript can properly narrow down the union type during runtime.
What is the best tool to convert complex JSON to TypeScript interfaces?
Tools like QuickType, json-to-ts, and TransformTools are highly recommended for converting complex JSON structures. QuickType specifically excels at detecting union types by allowing you to paste multiple JSON samples, helping it infer the correct TypeScript interfaces.
How to handle optional fields vs union types when converting JSON to TypeScript?
Optional fields are denoted with a question mark `?` in TypeScript, indicating the property might be absent, while a union type like `string | null` means the property is present but can be multiple types. When converting, ensure your tool distinguishes between a missing key and a key with a null or undefined value.
Why does my generated TypeScript interface have type 'any' for union properties?
This happens when the JSON to TypeScript converter cannot confidently infer the type from the provided data, often due to mixed types in arrays. To fix this, provide more comprehensive JSON samples or manually update the interface to use specific union types instead of 'any'.
How do I convert JSON arrays with mixed object types to TypeScript?
Converting JSON arrays with mixed object types requires defining a union type for the array elements, such as `Array<TypeA | TypeB>`. You will need to identify the distinct object shapes within the array and create separate interfaces for each, combining them with a union operator in the array declaration.
Is it possible to automatically generate TypeScript union types from Swagger or OpenAPI?
Yes, if your API documentation uses OpenAPI or Swagger, code generators like OpenAPI Generator can automatically create TypeScript interfaces with union types. These tools read the schema definitions directly, accurately mapping complex combinations like `oneOf` or `anyOf` to TypeScript union types.
How to convert JSON with nested union types to TypeScript interfaces?
For nested union types, you must define the inner interfaces first and then reference them within the parent interface using the union `|` operator. Manual refinement is usually necessary after the initial conversion to ensure the nested structures properly reflect all possible data variations.