All Articles

Converting JSON to TypeScript: Types, Interfaces, and Best Practices

·14 min read

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:

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:

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

  1. Copy a sample JSON response from your API (use your browser's DevTools Network tab or a tool like Postman).
  2. Paste it into the JSON to TypeScript tool.
  3. Review the generated interfaces and adjust naming if needed.
  4. 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.ts

This 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: The as Promise<T> cast on response.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:

  1. Grab a sample response from the API (DevTools, Postman, or curl).
  2. Format it with the JSON Formatter for readability.
  3. Generate initial types with the JSON to TypeScript tool.
  4. Refine manually — add optional markers, union types, and rename interfaces to match your project conventions.
  5. Navigate nested structures with the JSON Path Finder to understand deep nesting.
  6. Add Zod schemas for critical API boundaries where data integrity matters most.
  7. 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 literals

Converting 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.

Try These Tools

Related Articles

Enjoy this article? Buy us a coffee to support free tools and guides.