If you've worked with TypeScript for any length of time, you've encountered this scenario: you receive a JSON response from an API, and now you need to work with that data in a type-safe way. Without proper types, you lose autocompletion, refactoring support, and — most importantly — compile-time error checking. Converting JSON to TypeScript types is one of the most common tasks in modern web development, and doing it well can save you hours of debugging.
Why Type Your JSON Responses?
JavaScript is dynamically typed, which means you can access any property on any object without the compiler complaining. That's convenient until you misspell a property name, assume a field exists when it doesn't, or pass a string where a number is expected. TypeScript solves these problems — but only if you actually define types for your data.
Here's what you gain by typing JSON responses:
- Autocompletion — Your editor knows every field available on the object, including nested ones.
- Compile-time errors — Typos and wrong assumptions are caught before the code ever runs.
- Self-documenting code — Types serve as living documentation for your API contracts.
- Safer refactoring — Rename a field and TypeScript shows you every place that needs updating.
- Better collaboration — Team members understand data shapes without reading API docs.
Interfaces vs. Types: Which Should You Use?
TypeScript offers two ways to define object shapes: interface and type. For JSON data modeling, both work, but there are meaningful differences.
Interfaces
interface User {
id: number;
name: string;
email: string;
avatar_url: string | null;
}Interfaces support declaration merging — if you declare the same interface twice, TypeScript merges them. This is useful when extending third-party types but can cause surprises if you accidentally redeclare an interface. Interfaces are also slightly more performant in the compiler for large codebases because they're cached by name.
Type Aliases
type User = {
id: number;
name: string;
email: string;
avatar_url: string | null;
};Type aliases are more versatile. They can represent unions, intersections, mapped types, and conditional types — things interfaces cannot do directly. For JSON modeling specifically, types make it easier to compose complex shapes.
Practical Recommendation
For API response types, use interfaces for top-level objects and type aliases for unions and utilities. This gives you extensibility where it matters and flexibility where you need it:
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
type UserRole = "admin" | "editor" | "viewer";
interface User {
id: number;
name: string;
role: UserRole;
}Manual Conversion: Step by Step
Understanding manual conversion is essential even if you plan to use automated tools. It helps you make better decisions about optional fields, union types, and naming conventions.
Step 1: Start with the JSON
Take a real API response. You can use the JSON Formatter to pretty-print it first. Here's an example from a hypothetical blog API:
{
"id": 42,
"title": "Understanding TypeScript Generics",
"slug": "understanding-typescript-generics",
"author": {
"id": 7,
"name": "Jane Smith",
"bio": null
},
"tags": ["typescript", "programming", "tutorial"],
"published": true,
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-02-01T14:22:00Z",
"comments_count": 23,
"metadata": {
"reading_time": 8,
"word_count": 2400,
"featured": false
}
}Step 2: Identify the Primitive Types
Map each JSON value to its TypeScript equivalent:
- JSON
string→ TypeScriptstring - JSON
number(integer or float) → TypeScriptnumber - JSON
true/false→ TypeScriptboolean - JSON
null→ TypeScriptnull - JSON array → TypeScript
T[]orArray<T> - JSON object → a new interface or type
Step 3: Extract Nested Objects
Every nested object should become its own interface. This improves reusability and readability:
interface Author {
id: number;
name: string;
bio: string | null;
}
interface PostMetadata {
reading_time: number;
word_count: number;
featured: boolean;
}
interface BlogPost {
id: number;
title: string;
slug: string;
author: Author;
tags: string[];
published: boolean;
created_at: string;
updated_at: string;
comments_count: number;
metadata: PostMetadata;
}Step 4: Consider Optional Fields
Not every field is present in every response. A list endpoint might omit the full author object, or a draft post might lack published. Mark these with ?:
interface BlogPostSummary {
id: number;
title: string;
slug: string;
author?: Author; // Omitted in list view
tags: string[];
published?: boolean; // Missing for drafts
created_at: string;
}The difference between field?: string and field: string | null is important. Optional means the key might not exist on the object. Nullable means the key exists but its value could be null. Some fields are both: field?: string | null.
Handling Complex Patterns
Union Types for Polymorphic Responses
APIs often return different shapes based on a discriminator field. TypeScript's discriminated unions handle this elegantly:
interface TextBlock {
type: "text";
content: string;
}
interface ImageBlock {
type: "image";
url: string;
alt: string;
width: number;
height: number;
}
interface CodeBlock {
type: "code";
language: string;
source: string;
}
type ContentBlock = TextBlock | ImageBlock | CodeBlock;
interface Article {
id: number;
title: string;
blocks: ContentBlock[];
}With this setup, TypeScript narrows the type when you check block.type:
article.blocks.forEach((block) => {
switch (block.type) {
case "text":
console.log(block.content); // TypeScript knows this is TextBlock
break;
case "image":
console.log(block.url); // TypeScript knows this is ImageBlock
break;
case "code":
console.log(block.source); // TypeScript knows this is CodeBlock
break;
}
});Generic API Response Wrappers
Most APIs wrap their data in a consistent envelope. Model this with generics:
interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
per_page: number;
total: number;
total_pages: number;
};
}
interface ErrorResponse {
error: {
code: string;
message: string;
details?: Record<string, string[]>;
};
}
type ApiResult<T> = PaginatedResponse<T> | ErrorResponse;Index Signatures for Dynamic Keys
Some JSON objects have dynamic keys — for example, a translations object keyed by locale:
interface Translations {
[locale: string]: {
greeting: string;
farewell: string;
};
}
// Or more precisely with a union of known locales:
type Locale = "en" | "es" | "fr" | "de" | "ja";
type LocalizedStrings = Record<Locale, {
greeting: string;
farewell: string;
}>;Automating the Conversion
Manual conversion is educational, but for large or frequently changing APIs, automation is essential. The JSON to TypeScript converter on DuskTools lets you paste any JSON and get TypeScript interfaces instantly. It handles nested objects, arrays, nulls, and optional fields automatically.
Using the DuskTools Converter
- Copy a sample JSON response from your API (use your browser's DevTools Network tab or a tool like Postman).
- Paste it into the JSON to TypeScript tool.
- Review the generated interfaces and adjust naming if needed.
- Copy the output into your project's type definitions file.
Programmatic Generation with json-schema-to-typescript
If your API provides a JSON Schema or OpenAPI spec, you can generate types at build time:
npm install --save-dev json-schema-to-typescript
// generate-types.ts
import { compile } from "json-schema-to-typescript";
import { readFileSync, writeFileSync } from "fs";
const schema = JSON.parse(
readFileSync("./schemas/user.json", "utf-8")
);
compile(schema, "User").then((ts) => {
writeFileSync("./src/types/user.d.ts", ts);
});OpenAPI / Swagger Code Generation
For APIs with OpenAPI specs, tools like openapi-typescript generate types directly:
npx openapi-typescript https://api.example.com/openapi.json -o ./src/types/api.d.tsThis is the gold standard for large projects because types stay in sync with the API as the spec evolves.
Typing Fetch Calls and API Clients
Once you have your types, use them when making API calls:
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.status}`);
}
return response.json() as Promise<User>;
}
// With a generic helper:
async function api<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json() as Promise<T>;
}
const user = await api<User>("/api/users/1");
const posts = await api<PaginatedResponse<BlogPost>>("/api/posts?page=1");Important: Theas Promise<T>cast onresponse.json()tells TypeScript to trust that the data matches your type. It does not validate the data at runtime. If the API returns something unexpected, you'll get runtime errors despite having types. This is where runtime validation comes in.
Runtime Validation with Zod
TypeScript types exist only at compile time — they're erased when the code runs. For true safety, especially at API boundaries, use a runtime validation library like Zod:
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
avatar_url: z.string().url().nullable(),
role: z.enum(["admin", "editor", "viewer"]),
created_at: z.string().datetime(),
});
// Infer the TypeScript type from the schema — single source of truth
type User = z.infer<typeof UserSchema>;
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const json = await response.json();
return UserSchema.parse(json); // Throws ZodError if invalid
}
// Or use safeParse for non-throwing validation:
const result = UserSchema.safeParse(data);
if (result.success) {
console.log(result.data.name); // Fully typed
} else {
console.error(result.error.issues);
}Zod gives you a single source of truth: define the schema once and derive both the runtime validator and the TypeScript type from it. Other solid options include Yup, io-ts, and Valibot (a lightweight alternative to Zod).
Dealing with Dates and Special Types
JSON has no native date type. Dates are serialized as strings (usually ISO 8601). In TypeScript, you have a choice:
// Option 1: Keep as string (matches the raw JSON)
interface Event {
name: string;
starts_at: string; // ISO 8601
}
// Option 2: Use branded types for documentation
type ISODateString = string & { __brand: "ISODateString" };
interface Event {
name: string;
starts_at: ISODateString;
}
// Option 3: Transform to Date in your API layer
interface Event {
name: string;
starts_at: Date; // Parsed from JSON string
}Option 1 is simplest and matches the JSON exactly. Option 2 adds documentation without runtime cost. Option 3 requires a transformation step but gives you the full Date API.
Common Mistakes to Avoid
1. Using any as a Shortcut
// ❌ Defeats the entire purpose of TypeScript
const data: any = await response.json();
// ✅ Always type your responses
const data: User = await response.json();2. Not Handling Null vs. Undefined
JSON has null but not undefined. If a field is missing from JSON, it's undefined in JavaScript. If it's explicitly null in JSON, it's null. Model this accurately.
3. Assuming Array Element Types from One Example
// If you only saw ["typescript", "react"] you might type as:
tags: string[]
// But what if the API can also return tag objects?
// Always check the API docs or multiple responses
tags: string[] | Tag[]4. Over-Typing Internal Implementation Details
Don't create types for every intermediate transformation. Focus on the boundaries: API responses, form data, and function parameters.
Real-World Workflow
Here's a practical workflow for a team project:
- Grab a sample response from the API (DevTools, Postman, or curl).
- Format it with the JSON Formatter for readability.
- Generate initial types with the JSON to TypeScript tool.
- Refine manually — add optional markers, union types, and rename interfaces to match your project conventions.
- Navigate nested structures with the JSON Path Finder to understand deep nesting.
- Add Zod schemas for critical API boundaries where data integrity matters most.
- Store types in a shared
src/types/directory organized by domain (user.ts,posts.ts,billing.ts).
Quick Reference: JSON to TypeScript Mapping
// JSON value → TypeScript type
// ─────────────────────────────────────
// "hello" → string
// 42 → number
// 3.14 → number
// true / false → boolean
// null → null
// ["a", "b"] → string[]
// [1, "a", true] → (number | string | boolean)[]
// { "key": "value" } → { key: string } or interface
// nested object → separate interface
// ISO date string → string (or branded type)
// enum-like string → union of string literalsConverting JSON to TypeScript types is a foundational skill for any TypeScript developer. Whether you do it manually for learning or use automated tools for productivity, the result is the same: safer, more maintainable code with fewer runtime surprises. Start with the JSON to TypeScript converter for quick wins, and layer in Zod validation as your project grows.