Learn/Advanced Topics

TypeScript & JSON — Type Safety with Zod, io-ts & More

JSON.parse() returns any. That single gap breaks TypeScript's type safety at every API boundary. This guide shows you how to close that gap — with type guards, Zod schemas, io-ts codecs, generic fetch wrappers, and JSON import typing.

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 inside
4
5console.log(data.nmae); // Typo! No error at compile time.
6console.log(data.age.toUpperCase()); // Runtime crash — number has no toUpperCase
The Type Safety Gap

The 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}
6
7const 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

Type assertions (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}
6
7function 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}
16
17const data: unknown = JSON.parse(raw);
18
19if (isUser(data)) {
20 console.log(data.name); // TypeScript knows data is User here
21} else {
22 console.error('Invalid user data');
23}

Type guards are safe but tedious for complex types. For production code, use a library.

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';
3
4const User = t.type({
5 id: t.number,
6 name: t.string,
7 email: t.string,
8});
9
10type User = t.TypeOf<typeof User>;
11
12const result = User.decode(JSON.parse(raw));
13
14if (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

FeatureType AssertionType GuardZodio-ts
Runtime validationNoYesYesYes
Type inferenceManualManualAutomaticAutomatic
Error messagesNoneCustomDetailed pathsDetailed paths
Learning curveNoneLowLowMedium (fp-ts)
Transform supportNoNoYesYes
Bundle size0 KB0 KB~13 KB~40 KB (with fp-ts)
EcosystemN/AN/AReact Hook Form, tRPC, Next.jsfp-ts ecosystem

Importing JSON Files in TypeScript

TypeScript can import .json files directly with full type inference:

tsconfig.jsonjson
1{
2 "compilerOptions": {
3 "resolveJsonModule": true,
4 "esModuleInterop": true
5 }
6}
Importing JSONtypescript
1import config from './config.json';
2// TypeScript infers the exact type from the file contents:
3// { port: number; host: string; debug: boolean; ... }
4
5console.log(config.port); // Typed as number
6console.log(config.prot); // Compile error: Property 'prot' does not exist

Tip

JSON imports are evaluated at build time and included in the bundle. For large files or dynamic data, use fetch() with Zod validation instead.

Generic Type-Safe Fetch Wrapper

Reusable across your apptypescript
1import { z, ZodType } from 'zod';
2
3async 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}
11
12// Usage — fully type-safe, validated at runtime
13const 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

Frequently Asked Questions

Why does JSON.parse return "any" in TypeScript?
JSON.parse() can return any valid JSON value — string, number, boolean, null, object, or array. TypeScript cannot predict the shape at compile time, so the return type is "any". You need runtime validation (Zod, type guards, or io-ts) to safely narrow the type.
What is Zod and why should I use it?
Zod is a TypeScript-first schema declaration and validation library. You define a schema (like z.object({ name: z.string() })), and Zod both validates runtime data AND infers the TypeScript type from the schema. This means one source of truth for both validation and types.
What is the difference between a type assertion and a type guard?
A type assertion (as Type) tells TypeScript to trust you — no runtime check happens. A type guard is a function that performs a runtime check and narrows the type in the true branch. Type guards are safer because they actually verify the data.
Can I import JSON files in TypeScript?
Yes. Enable "resolveJsonModule": true and "esModuleInterop": true in tsconfig.json. Then import data from "./data.json". TypeScript will infer the exact type from the file contents, including literal types for constant values.
Should I use Zod or io-ts?
Zod is simpler, has better developer experience, and is more popular in the React/Next.js ecosystem. io-ts is more principled (based on fp-ts) and better for functional programming codebases. For most projects, Zod is the pragmatic choice.