TL;DR
Generate TypeScript interfaces from JSON with interfaces or type aliases. Use zod for runtime validation that generates TypeScript types automatically, or write type guards for narrowing. JSON.parse always returns 'any' β always validate at boundaries. Try our free JSON to TypeScript tool β
Key Takeaways
- TypeScript interfaces and type aliases both work for JSON typing β interfaces are preferred for objects
- Optional properties use
?suffix:name?: stringmeans the property may be absent - Union types model JSON arrays with heterogeneous values:
type Value = string | number | boolean | null - Generic interfaces create reusable API response wrappers:
interface ApiResponse<T> { data: T; error?: string } z.infer<typeof schema>generates TypeScript types from zod validation schemas automaticallyJSON.parsereturnsanyβ always validate with zod/io-ts at API boundaries instead of type assertions- Type guards (
function isUser(x: unknown): x is User) narrow types at runtime with compile-time safety
Why Use TypeScript Types for JSON?
JSON is the backbone of modern web APIs, but it is inherently untyped. When you fetch data from an endpoint and treat it as any, you lose all the benefits TypeScript provides: autocomplete, refactoring safety, and compile-time error detection. Assigning TypeScript interfaces to JSON data creates a contract between your code and your API.
There are two distinct levels of type safety for JSON:
| Level | When it catches errors | Tool |
|---|---|---|
| Compile-time | While writing code β wrong property names, missing fields | TypeScript interfaces / type aliases |
| Runtime | When the actual JSON arrives β API returns unexpected shape | zod, io-ts, valibot, type guards |
A common mistake is adding TypeScript types through assertions (as User) and assuming that provides runtime safety. It does not. TypeScript types are erased at compile time. Only runtime validation libraries actually check the incoming data.
Generate TypeScript interfaces from JSON instantly with our free tool β
interface vs type alias for JSON
TypeScript offers two ways to define the shape of a JSON object. Both are structurally equivalent for plain objects, but they have different capabilities.
// Given this JSON:
// { "id": 1, "name": "Alice", "email": "alice@example.com" }
// Option 1: interface
interface User {
id: number;
name: string;
email: string;
}
// Option 2: type alias
type User = {
id: number;
name: string;
email: string;
};
// Both work identically here:
const user: User = JSON.parse(response); // (unsafe β see Section 11)
console.log(user.name); // TypeScript knows this is a stringKey Differences
| Feature | interface | type alias |
|---|---|---|
| Object shapes | Yes | Yes |
| Declaration merging | Yes (extends across files) | No |
| Union types | No | Yes (type A = B | C) |
| Mapped types | Limited | Full support |
| Error messages | Shows interface name | Shows expanded type |
| Best for JSON objects | Preferred | Works, less idiomatic |
The TypeScript team's recommendation: use interface for object shapes, and type for unions, primitives, and computed types. For JSON API modeling, prefer interfaces.
Generating TypeScript Types from JSON: Manual Patterns
When converting JSON to TypeScript manually, follow a consistent mapping from JSON value types to TypeScript types:
// JSON input:
{
"id": 42,
"name": "Alice",
"score": 98.5,
"active": true,
"deletedAt": null,
"tags": ["admin", "user"],
"metadata": { "theme": "dark" }
}
// TypeScript interface output:
interface UserProfile {
id: number; // JSON number -> number
name: string; // JSON string -> string
score: number; // JSON number (float) -> number
active: boolean; // JSON boolean -> boolean
deletedAt: null; // JSON null -> null (or string | null if nullable)
tags: string[]; // JSON array of strings -> string[]
metadata: { // JSON object -> inline or named interface
theme: string;
};
}For the deletedAt field which could be a date string or null, use a union:
interface UserProfile {
deletedAt: string | null; // ISO date string or null
}
// Or with optional + nullable:
interface UserProfile {
deletedAt?: string | null; // missing, null, or date string
}Use our JSON to TypeScript tool to automate this mapping for large or complex JSON payloads.
Union Types from JSON Arrays with Mixed Values
JSON arrays can contain values of different types. TypeScript union types model this precisely:
// JSON: { "values": [1, "two", true, null, 3.14] }
// Heterogeneous array β union of all observed types:
interface Config {
values: Array<string | number | boolean | null>;
}
// Or use the type alias shorthand:
type JsonPrimitive = string | number | boolean | null;
type JsonArray = JsonPrimitive[];
// Recursive JSON value type (models any valid JSON):
type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
// Usage:
interface DynamicConfig {
settings: { [key: string]: JsonValue };
}The JsonValue recursive type is the most general representation of any valid JSON value. Use it for truly dynamic data, but prefer specific interfaces for known API shapes.
Optional Properties with ?
JSON APIs often return fields that may or may not be present. TypeScript's optional property syntax (?) captures this:
interface Post {
id: number;
title: string;
content: string;
author?: string; // may be absent (property not in JSON)
publishedAt?: string; // optional date
updatedAt: string | null; // always present, but can be null
tags?: string[]; // optional array
}
// Accessing optional properties safely:
const post: Post = getPost();
console.log(post.author?.toUpperCase()); // optional chaining
console.log(post.tags?.length ?? 0); // nullish coalescing
// Type narrowing after check:
if (post.author !== undefined) {
console.log(post.author.toUpperCase()); // TypeScript knows it's string here
}prop?: string means the property may be absent or undefined.prop: string | null means the property is always present but its value may be null. Use prop?: string | null for properties that can be both missing and null.Nested Interfaces and Type Composition
Real-world API responses often contain deeply nested objects. Define separate interfaces for each nested shape and compose them:
// JSON:
// {
// "order": {
// "id": "ORD-001",
// "customer": { "id": 42, "name": "Alice", "address": { "city": "NYC" } },
// "items": [{ "sku": "P1", "qty": 2, "price": 19.99 }]
// }
// }
// Separate interface for each nested shape:
interface Address {
street?: string;
city: string;
state?: string;
zip?: string;
}
interface Customer {
id: number;
name: string;
address: Address;
}
interface OrderItem {
sku: string;
qty: number;
price: number;
discount?: number;
}
interface Order {
id: string;
customer: Customer;
items: OrderItem[];
total?: number;
createdAt: string;
}
// Composition with intersection types:
type AdminOrder = Order & {
internalNote: string;
flaggedForReview: boolean;
};
// Using Partial<T> for update payloads:
type OrderUpdate = Partial<Pick<Order, 'total' | 'items'>>;TypeScript utility types like Partial<T>,Pick<T, K>,Omit<T, K>, andRequired<T> allow you to derive new types from existing ones without duplication.
Generic Interfaces for API Responses
Most REST APIs wrap responses in a consistent envelope. Generic interfaces eliminate the need to define separate wrappers for every endpoint:
// Generic API response envelope:
interface ApiResponse<T> {
data: T;
error?: string;
status: number;
meta?: {
page: number;
perPage: number;
total: number;
};
}
// Paginated list response:
interface PaginatedResponse<T> {
items: T[];
page: number;
pageSize: number;
total: number;
hasMore: boolean;
}
// Usage:
async function fetchUser(id: number): Promise<ApiResponse<User>> {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
async function listUsers(): Promise<PaginatedResponse<User>> {
const res = await fetch('/api/users');
return res.json();
}
// Consuming:
const response = await fetchUser(1);
if (response.error) {
console.error(response.error);
} else {
console.log(response.data.name); // TypeScript knows data is User
}Generic interfaces are powerful but still only provide compile-time safety. Theres.json() call returnsPromise<any> at runtime. For true safety, combine generics with runtime validation (see Section 8).
Using Zod for Runtime Validation: z.infer<typeof schema>
Zod is the leading TypeScript-first validation library. It defines schemas that serve as both the runtime validator and the TypeScript type source through z.infer:
import { z } from 'zod';
// Define schema once β get both validation and TypeScript types:
const UserSchema = z.object({
id: z.number().int().positive(),
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['admin', 'user', 'guest']),
createdAt: z.string().datetime(),
tags: z.array(z.string()).optional(),
settings: z.object({
theme: z.enum(['light', 'dark']).default('light'),
notifications: z.boolean(),
}).optional(),
});
// Derive TypeScript type automatically:
type User = z.infer<typeof UserSchema>;
// Equivalent to:
// type User = {
// id: number;
// name: string;
// email: string;
// role: 'admin' | 'user' | 'guest';
// createdAt: string;
// tags?: string[];
// settings?: { theme: 'light' | 'dark'; notifications: boolean };
// }
// Parse and validate (throws ZodError on failure):
const user: User = UserSchema.parse(rawData);
// Safe parse (returns result object instead of throwing):
const result = UserSchema.safeParse(rawData);
if (result.success) {
console.log(result.data.name); // User
} else {
console.error(result.error.format()); // Detailed error info
}Generic API Response with Zod
import { z } from 'zod';
// Factory function for generic response schemas:
const ApiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
z.object({
data: dataSchema,
status: z.number(),
error: z.string().optional(),
});
// Usage:
const UserResponseSchema = ApiResponseSchema(UserSchema);
type UserResponse = z.infer<typeof UserResponseSchema>;
// Validated fetch:
async function fetchUser(id: number): Promise<User> {
const raw = await fetch(`/api/users/${id}`).then(r => r.json());
const response = UserResponseSchema.parse(raw);
return response.data;
}npm install zod β compatible with Node.js, browsers, Deno, and Bun. No additional peer dependencies required.io-ts and valibot: Alternatives for Type-Safe Parsing
Besides zod, the ecosystem offers other type-safe parsing libraries with different design philosophies:
io-ts (fp-ts style)
import * as t from 'io-ts';
import { isRight } from 'fp-ts/Either';
// Define codec:
const UserCodec = t.type({
id: t.number,
name: t.string,
email: t.string,
role: t.union([t.literal('admin'), t.literal('user')]),
tags: t.union([t.array(t.string), t.undefined]),
});
// Extract TypeScript type:
type User = t.TypeOf<typeof UserCodec>;
// Decode (validates at runtime):
const decoded = UserCodec.decode(rawData);
if (isRight(decoded)) {
const user: User = decoded.right; // type-safe
} else {
console.error('Validation failed:', decoded.left);
}valibot (tree-shakable, minimal bundle)
import { object, number, string, optional, array, parse, InferOutput } from 'valibot';
const UserSchema = object({
id: number(),
name: string(),
email: string(),
tags: optional(array(string())),
});
// Extract TypeScript type:
type User = InferOutput<typeof UserSchema>;
// Parse:
const user: User = parse(UserSchema, rawData);| Library | Bundle size | API style | Best for |
|---|---|---|---|
| zod | ~13 KB (min+gz) | Fluent chain | Most projects, great DX |
| io-ts | ~7 KB | Functional (fp-ts) | fp-ts codebases |
| valibot | ~1.5 KB (tree-shaken) | Modular functions | Bundle-size critical apps |
Type Guards: Runtime Narrowing with Compile-Time Safety
Type guards are functions with the return type x is T that teach TypeScript's control flow analysis about runtime type checks:
interface User {
id: number;
name: string;
email: string;
}
// Type guard β predicate function:
function isUser(x: unknown): x is User {
return (
typeof x === 'object' &&
x !== null &&
typeof (x as Record<string, unknown>).id === 'number' &&
typeof (x as Record<string, unknown>).name === 'string' &&
typeof (x as Record<string, unknown>).email === 'string'
);
}
// Usage β TypeScript narrows the type in the if-block:
const raw: unknown = JSON.parse(responseText);
if (isUser(raw)) {
// raw is typed as User here:
console.log(raw.name.toUpperCase()); // no error
console.log(raw.email.split('@')[0]); // TypeScript knows this is string
} else {
throw new Error('Invalid user data');
}Discriminated Unions
// Discriminated union β common pattern for polymorphic JSON:
interface SuccessResponse {
status: 'success';
data: User;
}
interface ErrorResponse {
status: 'error';
message: string;
code: number;
}
type ApiResponse = SuccessResponse | ErrorResponse;
function handleResponse(res: ApiResponse) {
switch (res.status) {
case 'success':
console.log(res.data.name); // TypeScript narrows to SuccessResponse
break;
case 'error':
console.error(`Error ${res.code}: ${res.message}`);
break;
}
}JSON.parse with Type Assertion (and Why It's Unsafe)
JSON.parse returns any in TypeScript. This means the following compiles without errors but is silently wrong at runtime:
// UNSAFE: type assertion bypasses runtime checking
const user = JSON.parse(responseText) as User;
console.log(user.name.toUpperCase()); // runtime error if 'name' is missing or not a string
// ALSO UNSAFE: generic type parameter on JSON.parse doesn't exist (this is wrong TS):
// JSON.parse<User>(str) // <-- not a real TypeScript feature!
// SAFE option 1: validate with zod
import { z } from 'zod';
const UserSchema = z.object({ id: z.number(), name: z.string() });
const safe1 = UserSchema.parse(JSON.parse(responseText)); // throws on invalid data
// SAFE option 2: use unknown, then type guard
const raw: unknown = JSON.parse(responseText);
if (!isUser(raw)) throw new Error('Invalid user data');
const safe2: User = raw; // narrowed by type guard
// SAFE option 3: Response.json() with zod in fetch:
const data = await fetch('/api/user').then(r => r.json());
const safe3 = UserSchema.parse(data);as SomeType after JSON.parse in production code. Type assertions are a promise to TypeScript that you know better than the compiler β and with external data, you never do until you validate it.Fetch API Pattern with TypeScript Types
A production-ready pattern for typed HTTP fetching combines generic functions with zod validation:
import { z } from 'zod';
// Generic typed fetch utility:
async function fetchTyped<T>(
url: string,
schema: z.ZodType<T>,
options?: RequestInit
): Promise<T> {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error ${response.status}: ${response.statusText}`);
}
const raw: unknown = await response.json();
return schema.parse(raw); // throws ZodError if shape is wrong
}
// Define schemas:
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
createdAt: z.string().datetime(),
});
const UsersListSchema = z.array(UserSchema);
type User = z.infer<typeof UserSchema>;
// Usage β fully type-safe:
const user = await fetchTyped('/api/users/1', UserSchema);
const users = await fetchTyped('/api/users', UsersListSchema);
// user.name is string β guaranteed at both compile and runtime
// users is User[] β each element validated against UserSchemaUsing React Query with Typed Fetching
import { useQuery } from '@tanstack/react-query';
function useUser(id: number) {
return useQuery({
queryKey: ['user', id],
queryFn: () => fetchTyped(`/api/users/${id}`, UserSchema),
// data is typed as User (not any) because fetchTyped returns Promise<User>
});
}
// In component:
function UserCard({ id }: { id: number }) {
const { data: user, isLoading, error } = useUser(id);
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <div>{user.name}</div>; // user is User β fully typed
}Tools for Generating TypeScript from JSON
Several tools automate the conversion from JSON samples to TypeScript interfaces:
| Tool | Output | Notes |
|---|---|---|
| DevToolBox JSON to TypeScript | interface / type alias | Free online, handles nested objects |
| quicktype | TypeScript + zod schemas | CLI + API, many language targets |
| json-to-ts (npm) | TypeScript interfaces | Programmatic use in build pipelines |
| TypeScript compiler (tsc) | N/A β infers from usage | Best for known data shapes |
| OpenAPI Generator | Full SDK + types | For APIs with OpenAPI/Swagger spec |
The DevToolBox JSON to TypeScript converter accepts any valid JSON input and generates clean TypeScript interfaces with proper nesting, optional property detection, and array type inference. No installation required.
Try the JSON to TypeScript converter at viadreams.cc β
Common Pitfalls: any vs unknown, JSON.parse, and Number Precision
Pitfall 1: Using any Instead of unknown
// BAD: any disables all type checking
function processData(data: any) {
data.nonExistentMethod(); // No TypeScript error β runtime crash!
}
// GOOD: unknown forces narrowing before use
function processData(data: unknown) {
if (typeof data === 'string') {
data.toUpperCase(); // OK β narrowed to string
} else if (isUser(data)) {
data.name; // OK β narrowed to User
}
}
// In function return types:
// BAD:
async function fetchRaw(): Promise<any> { /* ... */ }
// GOOD:
async function fetchRaw(): Promise<unknown> { /* ... */ }Pitfall 2: JSON.parse Returns any
// TypeScript's lib.es2015.core.d.ts:
// interface JSON {
// parse(text: string, reviver?: (key: any, value: any) => any): any;
// }
// This is by design β TypeScript cannot know what JSON.parse returns.
// The fix: use unknown and validate:
const raw: unknown = JSON.parse(text); // assign to unknown explicitly
const user = UserSchema.parse(raw); // validate before usePitfall 3: Number Precision Loss with Large Integers
// JavaScript numbers are 64-bit floats.
// Integers larger than 2^53 - 1 lose precision:
const bigId = JSON.parse('{"id": 9007199254740993}');
console.log(bigId.id); // 9007199254740992 β wrong! Off by 1
// Solution 1: Use string IDs in your API
// { "id": "9007199254740993" }
interface Entity { id: string; } // string is safe
// Solution 2: Use BigInt with a reviver function
const data = JSON.parse('{"id": 9007199254740993}', (key, value, ctx) => {
// ctx.source available in modern environments
return value;
});
// Solution 3: Use a JSON library that supports BigInt
// (e.g., json-bigint npm package)Pitfall 4: Forgetting to Narrow After Validation
// Even with zod, be careful with optional fields:
const UserSchema = z.object({
name: z.string(),
bio: z.string().optional(), // bio?: string | undefined
});
type User = z.infer<typeof UserSchema>;
// { name: string; bio?: string }
const user = UserSchema.parse(raw);
// BAD: may be undefined
console.log(user.bio.toUpperCase()); // Error at runtime!
// GOOD: check first
if (user.bio) {
console.log(user.bio.toUpperCase()); // safe
}
console.log(user.bio?.toUpperCase()); // optional chainingFrequently Asked Questions
Q: Should I use interface or type alias for JSON in TypeScript?
Interfaces are preferred for JSON object shapes β they're extendable and clearer in error messages. Use interface User { id: number; name: string } for objects. Type aliases shine for union types, primitives, and tuples: type ID = string | number. Both are structurally equivalent for plain JSON objects, but interfaces support declaration merging which is useful for module augmentation.
Q: How do I handle optional JSON fields in TypeScript?
Use the optional property operator ?: property?: string means the property can be absent or undefined. For nullable values use string | null. Combine both for a field that may be missing or null: property?: string | null. With zod use z.string().optional() for missing fields and z.string().nullable() for null values. z.string().nullish() covers both.
Q: What is the difference between JSON.parse and a validated parse?
JSON.parse returns 'any' β you lose type safety immediately. A validated parse (using zod.parse, io-ts, or valibot) checks the shape at runtime and throws a meaningful error if the data doesn't match. With zod: const user = UserSchema.parse(JSON.parse(str)); This both validates and narrows the type. Using as User after JSON.parse is a type assertion that can hide runtime bugs.
Q: How do I create types for nested JSON objects?
Define separate interfaces for each nested object and compose them. interface Address { street: string; city: string } interface User { id: number; address: Address } This is cleaner than inlining nested types and allows reuse. For deeply nested or recursive structures (like tree nodes) use: interface TreeNode { value: string; children?: TreeNode[] }
Q: How does zod work with TypeScript?
zod lets you define schemas and derive types with z.infer<typeof schema>. Example: const UserSchema = z.object({ id: z.number(), name: z.string() }); type User = z.infer<typeof UserSchema>; The type and the runtime validator stay in sync automatically. When you update the schema, the inferred type updates too β no drift between types and validation logic.
Q: What is a type guard in TypeScript?
A type guard is a function returning 'x is T' that narrows types at runtime using TypeScript's control flow analysis. Example: function isUser(x: unknown): x is User { return typeof x === 'object' && x !== null && 'id' in x && 'name' in x; } After calling if (isUser(data)), TypeScript knows data is User inside the block. Type guards work without any library dependency.
Q: How do I type a generic API response wrapper?
Use generic interfaces: interface ApiResponse<T> { data: T; error?: string; status: number } Then use it as: const res: ApiResponse<User[]> = await fetchUsers(); This lets you create one wrapper type that works for any payload shape. Combine with zod: const ApiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) => z.object({ data: dataSchema, status: z.number() });
Q: What's the safest way to type JSON.parse output?
Use 'unknown' then validate: const parsed: unknown = JSON.parse(str); const user = UserSchema.parse(parsed); Never use as User directly β it bypasses runtime checking. If you cannot use zod, write a type guard that checks each required property. Returning unknown instead of any forces callers to narrow the type before use.
Ready to convert your JSON to TypeScript interfaces?
Paste any JSON and get clean TypeScript interfaces instantly. Handles nested objects, arrays, optional fields, and union types.
Open JSON to TypeScript Converter β