DevToolBoxGRATIS
Blog

JSON a Interfaz TypeScript: Guía Completa con Zod y Type Guards

11 min de lecturapor DevToolBox

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?: string means 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 automatically
  • JSON.parse returns any — 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:

LevelWhen it catches errorsTool
Compile-timeWhile writing code — wrong property names, missing fieldsTypeScript interfaces / type aliases
RuntimeWhen the actual JSON arrives — API returns unexpected shapezod, 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 string

Key Differences

Featureinterfacetype alias
Object shapesYesYes
Declaration mergingYes (extends across files)No
Union typesNoYes (type A = B | C)
Mapped typesLimitedFull support
Error messagesShows interface nameShows expanded type
Best for JSON objectsPreferredWorks, 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
}
Important distinction: 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;
}
Install zod: 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);
LibraryBundle sizeAPI styleBest for
zod~13 KB (min+gz)Fluent chainMost projects, great DX
io-ts~7 KBFunctional (fp-ts)fp-ts codebases
valibot~1.5 KB (tree-shaken)Modular functionsBundle-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);
Rule: Never use 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 UserSchema

Using 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:

ToolOutputNotes
DevToolBox JSON to TypeScriptinterface / type aliasFree online, handles nested objects
quicktypeTypeScript + zod schemasCLI + API, many language targets
json-to-ts (npm)TypeScript interfacesProgrammatic use in build pipelines
TypeScript compiler (tsc)N/A — infers from usageBest for known data shapes
OpenAPI GeneratorFull SDK + typesFor 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 use

Pitfall 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 chaining

Frequently 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 →
𝕏 Twitterin LinkedIn
¿Fue útil?

Mantente actualizado

Recibe consejos de desarrollo y nuevas herramientas.

Sin spam. Cancela cuando quieras.

Prueba estas herramientas relacionadas

TSJSON to TypeScript{ }JSON FormatterJSON ValidatorTSTypeScript Playground

Artículos relacionados

JSON a TypeScript Online: La guia completa para desarrolladores

Aprende a generar tipos TypeScript desde JSON automaticamente. Interface vs type, campos opcionales/nullable, objetos anidados, tipos union, validacion Zod, tipos API genericos y mejores practicas tsconfig.

JSON a Struct Go: La Guía Completa de Conversión para 2026

Aprende a convertir JSON en structs de Go con tags json, tipos anidados, punteros nullable y encoding/json.