The Problem: JSON.parse() Returns any
1const raw = '{"name": "Alice", "age": 30}';2const data = JSON.parse(raw);3// data is "any" — TypeScript has no idea what's inside45console.log(data.nmae); // Typo! No error at compile time.6console.log(data.age.toUpperCase()); // Runtime crash — number has no toUpperCaseThe solution: add a validation layer between JSON.parse() and your code.
Approach 1: Type Assertions (Unsafe)
The simplest but most dangerous approach — you tell TypeScript to trust the shape:
1interface User {2 id: number;3 name: string;4 email: string;5}67const data = JSON.parse(raw) as User;8// TypeScript now treats data as User — but NO runtime check happens.9// If the JSON is missing "email", data.email is undefined and code may crash later.Warning
as Type) perform zero runtime validation. Use them only when you fully control and trust the data source (e.g., reading a file you wrote).Approach 2: Type Guards (Manual Runtime Check)
A type guard is a function that checks the data at runtime and narrows the type:
1interface User {2 id: number;3 name: string;4 email: string;5}67function isUser(value: unknown): value is User {8 return (9 typeof value === 'object' &&10 value !== null &&11 'id' in value && typeof (value as Record<string, unknown>).id === 'number' &&12 'name' in value && typeof (value as Record<string, unknown>).name === 'string' &&13 'email' in value && typeof (value as Record<string, unknown>).email === 'string'14 );15}1617const data: unknown = JSON.parse(raw);1819if (isUser(data)) {20 console.log(data.name); // TypeScript knows data is User here21} else {22 console.error('Invalid user data');23}Type guards are safe but tedious for complex types. For production code, use a library.
Approach 3: Zod (Recommended)
Zod lets you define a schema once and get both runtime validation and TypeScript types:
1import { z } from 'zod';23const UserSchema = z.object({4 id: z.number(),5 name: z.string().min(1),6 email: z.string().email(),7 role: z.enum(['admin', 'editor', 'viewer']),8 active: z.boolean().default(true),9 tags: z.array(z.string()).optional(),10});1112// Infer the TypeScript type from the schema13type User = z.infer<typeof UserSchema>;14// User = { id: number; name: string; email: string; role: "admin" | "editor" | "viewer"; active: boolean; tags?: string[] }1const raw = JSON.parse(responseBody);23const result = UserSchema.safeParse(raw);45if (result.success) {6 const user: User = result.data;7 console.log(user.name); // Fully typed, validated at runtime8} else {9 console.error('Validation errors:', result.error.issues);10 // [{ path: ["email"], message: "Invalid email", code: "invalid_string" }]11}Zod with fetch()
1async function fetchUser(id: number): Promise<User> {2 const response = await fetch(`/api/users/${id}`);3 if (!response.ok) throw new Error(`HTTP ${response.status}`);45 const json = await response.json();6 return UserSchema.parse(json); // Throws ZodError if invalid7}89const user = await fetchUser(42);10// user is fully typed as User — guaranteed at runtimeZod Advanced Patterns
1const AddressSchema = z.object({2 street: z.string(),3 city: z.string(),4 zip: z.string().regex(/^d{5}(-d{4})?$/),5 country: z.string().length(2),6});78const UserWithAddressSchema = UserSchema.extend({9 address: AddressSchema.optional(),10 metadata: z.record(z.string(), z.unknown()),11});1213// Transform during validation14const ApiUserSchema = z.object({15 first_name: z.string(),16 last_name: z.string(),17}).transform(data => ({18 fullName: `${data.first_name} ${data.last_name}`,19}));Approach 4: io-ts (Functional Programming)
io-ts uses fp-ts and provides codecs that both decode and encode:
1import * as t from 'io-ts';2import { isRight } from 'fp-ts/Either';34const User = t.type({5 id: t.number,6 name: t.string,7 email: t.string,8});910type User = t.TypeOf<typeof User>;1112const result = User.decode(JSON.parse(raw));1314if (isRight(result)) {15 const user: User = result.right;16 console.log(user.name);17} else {18 console.error('Validation failed:', result.left);19}Comparison: Zod vs io-ts vs Type Guards
| Feature | Type Assertion | Type Guard | Zod | io-ts |
|---|---|---|---|---|
| Runtime validation | No | Yes | Yes | Yes |
| Type inference | Manual | Manual | Automatic | Automatic |
| Error messages | None | Custom | Detailed paths | Detailed paths |
| Learning curve | None | Low | Low | Medium (fp-ts) |
| Transform support | No | No | Yes | Yes |
| Bundle size | 0 KB | 0 KB | ~13 KB | ~40 KB (with fp-ts) |
| Ecosystem | N/A | N/A | React Hook Form, tRPC, Next.js | fp-ts ecosystem |
Importing JSON Files in TypeScript
TypeScript can import .json files directly with full type inference:
1{2 "compilerOptions": {3 "resolveJsonModule": true,4 "esModuleInterop": true5 }6}1import config from './config.json';2// TypeScript infers the exact type from the file contents:3// { port: number; host: string; debug: boolean; ... }45console.log(config.port); // Typed as number6console.log(config.prot); // Compile error: Property 'prot' does not existTip
fetch() with Zod validation instead.Generic Type-Safe Fetch Wrapper
1import { z, ZodType } from 'zod';23async function fetchJson<T>(url: string, schema: ZodType<T>): Promise<T> {4 const response = await fetch(url);5 if (!response.ok) {6 throw new Error(`HTTP ${response.status}: ${response.statusText}`);7 }8 const json = await response.json();9 return schema.parse(json);10}1112// Usage — fully type-safe, validated at runtime13const user = await fetchJson('/api/users/1', UserSchema);14const posts = await fetchJson('/api/posts', z.array(PostSchema));Try It — Validate a TypeScript-Style JSON Object
Try It Yourself
Valid user object — matches the Zod schema from this guide