JSON in TypeScript #
JSON (JavaScript Object Notation) is the most common data exchange format in the TypeScript ecosystem — used for API responses, configuration files, data storage, and inter-service communication. Although JSON.parse and JSON.stringify look simple, there are many traps hidden beneath the surface: JSON.parse returns any, which disables all type checking; some JavaScript values can’t be serialized; Date becomes a string after a roundtrip; and Map/Set become empty {}. This article covers all aspects of JSON in TypeScript in depth — from correct basic usage and strict type validation to safe patterns for production code.
JSON.stringify — Serializing to a String
#
JSON.stringify converts a JavaScript value into a JSON string. TypeScript knows this function returns string:
// Basic usage
const pengguna = {
id: "usr-001",
nama: "Budi Santoso",
email: "[email protected]",
usia: 25,
aktif: true,
};
const json = JSON.stringify(pengguna);
// '{"id":"usr-001","nama":"Budi Santoso","email":"[email protected]","usia":25,"aktif":true}'
// Second parameter: replacer — filter or transform
const jsonTanpaEmail = JSON.stringify(pengguna, (key, value) => {
if (key === "email") return undefined; // undefined = remove the property
return value;
});
// '{"id":"usr-001","nama":"Budi Santoso","usia":25,"aktif":true}'
// Or use an array as a property whitelist
const jsonTerpilih = JSON.stringify(pengguna, ["id", "nama"]);
// '{"id":"usr-001","nama":"Budi Santoso"}'
// Third parameter: indentation for readable output
const jsonRapi = JSON.stringify(pengguna, null, 2);
// {
// "id": "usr-001",
// "nama": "Budi Santoso",
// ...
// }
Non-Serializable Values #
Some JavaScript types are ignored or changed during serialization:
const dataBermasalah = {
fungsi: () => "halo", // ✗ undefined → property removed
simbol: Symbol("test"), // ✗ undefined → property removed
tidakTerdefinisi: undefined, // ✗ property removed
tanggal: new Date("2025-05-07"), // Changed to an ISO string
tak_terhingga: Infinity, // ✗ null
bukan_angka: NaN, // ✗ null
bigint: 9007199254740993n, // ✗ TypeError: Do not know how to serialize a BigInt
};
console.log(JSON.stringify(dataBermasalah));
// '{"tanggal":"2025-05-07T00:00:00.000Z","tak_terhingga":null,"bukan_angka":null}'
// Functions, symbols, and undefined disappear without warning!
// Map and Set lose all their data
const map = new Map([["kunci", "nilai"]]);
const set = new Set([1, 2, 3]);
console.log(JSON.stringify({ map, set })); // '{"map":{},"set":{}}'
JSON.stringifydoesn’t throw an error for non-serializable values — it silently removes those properties or replaces them withnull. This bug is very dangerous because it’s undetected at compile time and at runtime without explicit testing. Always verify theJSON.stringifyoutput for objects containingDate,Map,Set,undefined, or custom values.
JSON.parse — Deserialization and the any Trap
#
JSON.parse is the main source of type safety loss in TypeScript — it returns any:
const jsonString = '{"id":"usr-001","nama":"Budi","usia":25}';
// ANTI-PATTERN: Using the parse result directly without validation
const data = JSON.parse(jsonString); // Type: any
data.nama.toUpperCase(); // ✓ No TypeScript error
data.tidakAda.tidakAda; // ✓ No TypeScript error — but crashes at runtime!
// ANTI-PATTERN: Type assertion without validation
const pengguna = JSON.parse(jsonString) as Pengguna;
// TypeScript believes this is a Pengguna, but there's no runtime guarantee
// CORRECT: Validate with Zod before use
import { z } from "zod";
const SchemaPengguna = z.object({
id: z.string(),
nama: z.string(),
usia: z.number().min(0).max(150),
email: z.string().email().optional(),
});
type Pengguna = z.infer<typeof SchemaPengguna>;
function parseJSON<T>(schema: z.ZodSchema<T>, input: string): T {
let parsed: unknown;
try {
parsed = JSON.parse(input);
} catch {
throw new SyntaxError("Input bukan JSON yang valid");
}
const hasil = schema.safeParse(parsed);
if (!hasil.success) {
throw new TypeError(
`Struktur JSON tidak sesuai: ${JSON.stringify(hasil.error.flatten())}`
);
}
return hasil.data;
}
// Safe usage
const pengguna = parseJSON(SchemaPengguna, jsonString);
// pengguna.nama → string (TypeScript knows the type!)
// pengguna.email → string | undefined
Replacer Functions — Custom Serialization #
A replacer function gives full control over what’s included and how values are serialized:
// Case 1: Serializing a Date as a Unix timestamp
function replacerTimestamp(key: string, value: unknown): unknown {
if (value instanceof Date) {
return { __type: "Date", value: value.getTime() };
}
return value;
}
const data = {
nama: "Jadwal Sholat",
dibuat: new Date("2025-05-07T12:00:00Z"),
};
const json = JSON.stringify(data, replacerTimestamp);
// '{"nama":"Jadwal Sholat","dibuat":{"__type":"Date","value":1746619200000}}'
// Case 2: Serializing Map and Set
function replacerKoleksi(key: string, value: unknown): unknown {
if (value instanceof Map) {
return { __type: "Map", entries: [...value.entries()] };
}
if (value instanceof Set) {
return { __type: "Set", values: [...value.values()] };
}
if (typeof value === "bigint") {
return { __type: "BigInt", value: value.toString() };
}
return value;
}
const dataMap = {
konfigurasi: new Map([["host", "localhost"], ["port", "5432"]]),
tag: new Set(["typescript", "nodejs"]),
id: 9007199254740993n,
};
const jsonMap = JSON.stringify(dataMap, replacerKoleksi, 2);
Reviver Functions — Custom Deserialization #
A reviver is the opposite of a replacer — it runs during JSON.parse and enables value transformation during deserialization:
// Reviver to restore Dates from ISO strings
function reviverTanggal(key: string, value: unknown): unknown {
if (typeof value === "string") {
// Check whether the string is in ISO 8601 format
const polaISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
if (polaISO.test(value)) {
return new Date(value);
}
}
return value;
}
const jsonDenganTanggal = '{"nama":"Acara","mulai":"2025-05-07T08:00:00.000Z"}';
const acara = JSON.parse(jsonDenganTanggal, reviverTanggal);
console.log(acara.mulai instanceof Date); // true
console.log(acara.mulai.getFullYear()); // 2025
// Reviver to restore Maps from a custom representation
function reviverKoleksi(key: string, value: unknown): unknown {
if (typeof value === "object" && value !== null) {
const obj = value as Record<string, unknown>;
if (obj.__type === "Date" && typeof obj.value === "number") {
return new Date(obj.value);
}
if (obj.__type === "Map" && Array.isArray(obj.entries)) {
return new Map(obj.entries as [unknown, unknown][]);
}
if (obj.__type === "Set" && Array.isArray(obj.values)) {
return new Set(obj.values as unknown[]);
}
if (obj.__type === "BigInt" && typeof obj.value === "string") {
return BigInt(obj.value);
}
}
return value;
}
The toJSON Method on Classes
#
Classes can define a toJSON() method that JSON.stringify calls automatically. This is the idiomatic way to control class serialization:
class Uang {
constructor(
private readonly jumlah: number,
private readonly matauang: string
) {}
// JSON.stringify will call this automatically
toJSON(): { jumlah: number; matauang: string; format: string } {
return {
jumlah: this.jumlah,
matauang: this.matauang,
format: `${this.matauang} ${this.jumlah.toLocaleString("id-ID")}`,
};
}
static dariJSON(data: { jumlah: number; matauang: string }): Uang {
return new Uang(data.jumlah, data.matauang);
}
}
class Produk {
constructor(
public readonly id: string,
public readonly nama: string,
public readonly harga: Uang,
private readonly passwordInternal: string // Must NOT be serialized!
) {}
toJSON() {
return {
id: this.id,
nama: this.nama,
harga: this.harga, // Uang.toJSON() will be called nested
// passwordInternal deliberately not included
};
}
}
const produk = new Produk("PRD-001", "Kurma Ajwa", new Uang(85_000, "IDR"), "rahasia");
console.log(JSON.stringify(produk, null, 2));
// {
// "id": "PRD-001",
// "nama": "Kurma Ajwa",
// "harga": {
// "jumlah": 85000,
// "matauang": "IDR",
// "format": "IDR 85.000"
// }
// }
// passwordInternal doesn't appear!
JSON Schema Validation with Zod #
Zod is a very powerful schema validation library for TypeScript. It generates TypeScript types automatically from schemas:
import { z } from "zod";
// A complex schema with transformations
const SchemaTransaksi = z.object({
id: z.string().uuid("ID harus berformat UUID"),
jumlah: z.number().positive("Jumlah harus positif"),
matauang: z.enum(["IDR", "USD", "EUR"]),
waktu: z.string().datetime().transform((s) => new Date(s)), // string → Date
metadata: z.record(z.string(), z.unknown()).optional(),
status: z.enum(["menunggu", "diproses", "selesai", "gagal"]).default("menunggu"),
item: z.array(z.object({
produkId: z.string(),
kuantitas: z.number().int().min(1),
harga: z.number().positive(),
})).min(1, "Transaksi harus memiliki minimal 1 item"),
});
type Transaksi = z.infer<typeof SchemaTransaksi>;
// TypeScript knows waktu is of type Date (already transformed)
// Validation with rich errors
const jsonTransaksi = `{
"id": "550e8400-e29b-41d4-a716-446655440000",
"jumlah": 150000,
"matauang": "IDR",
"waktu": "2025-05-07T08:30:00Z",
"item": [
{ "produkId": "PRD-001", "kuantitas": 2, "harga": 75000 }
]
}`;
const hasil = SchemaTransaksi.safeParse(JSON.parse(jsonTransaksi));
if (hasil.success) {
const transaksi: Transaksi = hasil.data;
console.log(`Transaksi ${transaksi.id}: ${transaksi.jumlah} ${transaksi.matauang}`);
console.log(`Waktu: ${transaksi.waktu.toISOString()}`); // waktu is a Date!
} else {
console.error("Validasi gagal:", hasil.error.flatten());
}
JSON from Files and APIs #
Reading JSON from Files #
import { readFile } from "fs/promises";
import { z } from "zod";
const SchemaKonfigurasi = z.object({
database: z.object({
host: z.string(),
port: z.coerce.number(),
nama: z.string(),
}),
server: z.object({
port: z.coerce.number().default(3000),
env: z.enum(["development", "production", "test"]).default("development"),
}),
fitur: z.record(z.string(), z.boolean()).optional(),
});
type Konfigurasi = z.infer<typeof SchemaKonfigurasi>;
async function muatKonfigurasi(filePath: string): Promise<Konfigurasi> {
let isiFile: string;
try {
isiFile = await readFile(filePath, "utf-8");
} catch {
throw new Error(`Gagal membaca file konfigurasi: ${filePath}`);
}
let dataRaw: unknown;
try {
dataRaw = JSON.parse(isiFile);
} catch {
throw new SyntaxError(`File ${filePath} bukan JSON yang valid`);
}
const hasil = SchemaKonfigurasi.safeParse(dataRaw);
if (!hasil.success) {
const errors = hasil.error.flatten().fieldErrors;
throw new TypeError(
`Konfigurasi tidak valid:\n${JSON.stringify(errors, null, 2)}`
);
}
return hasil.data;
}
Reading JSON from APIs #
const SchemaResponseAPI = z.object({
sukses: z.boolean(),
data: z.array(z.object({
id: z.string(),
nama: z.string(),
harga: z.number(),
})),
meta: z.object({
total: z.number(),
halaman: z.number(),
perHalaman: z.number(),
}),
});
type ResponseAPI = z.infer<typeof SchemaResponseAPI>;
async function ambilProduk(halaman = 1): Promise<ResponseAPI> {
const response = await fetch(`https://api.example.com/produk?halaman=${halaman}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json: unknown = await response.json();
const hasil = SchemaResponseAPI.safeParse(json);
if (!hasil.success) {
console.error("Struktur response API tidak sesuai ekspektasi:", hasil.error.flatten());
throw new TypeError("Format response API tidak valid");
}
return hasil.data;
}
JSON Lines — Streaming Large Data #
JSON Lines (JSONL) is a format where each line is one JSON object — ideal for log files, data exports, and streaming:
import { createReadStream } from "fs";
import * as readline from "readline";
import { z } from "zod";
const SchemaEntriLog = z.object({
waktu: z.string(),
level: z.enum(["debug", "info", "warn", "error"]),
pesan: z.string(),
requestId: z.string().optional(),
});
type EntriLog = z.infer<typeof SchemaEntriLog>;
async function* bacaLogStream(filePath: string): AsyncGenerator<EntriLog> {
const fileStream = createReadStream(filePath, { encoding: "utf-8" });
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
for await (const baris of rl) {
if (baris.trim() === "") continue; // Skip empty lines
try {
const data = JSON.parse(baris);
const hasil = SchemaEntriLog.safeParse(data);
if (hasil.success) {
yield hasil.data;
}
} catch {
// Skip lines that aren't valid JSON
}
}
}
// Write JSONL
function tulisEntriLog(entri: EntriLog): string {
return JSON.stringify(entri); // One JSON object per line, no newline
}
// Usage
async function analisisLog(filePath: string): Promise<void> {
let jumlahError = 0;
let jumlahTotal = 0;
for await (const entri of bacaLogStream(filePath)) {
jumlahTotal++;
if (entri.level === "error") jumlahError++;
}
console.log(`Total: ${jumlahTotal}, Error: ${jumlahError} (${((jumlahError / jumlahTotal) * 100).toFixed(1)}%)`);
}
JSON5 and JSONC — For Configuration Files #
Standard JSON is very strict — no comments, trailing commas, or special values allowed. For human-readable configuration files, consider more tolerant formats:
// JSONC (JSON with Comments) — used by tsconfig.json, .vscode/settings.json
// Supported by: VS Code, TypeScript, some linters
// tsconfig.json may contain comments:
// {
// "compilerOptions": {
// "strict": true, // Enable all strict checks
// "target": "ES2022"
// }
// }
// JSON5 — a JSON superset with more tolerant syntax
// npm install json5
import JSON5 from "json5";
const konfigJSON5 = `{
// Comments are allowed
host: 'localhost', // Unquoted keys are allowed
port: 5432,
// Trailing commas are fine
ssl: false,
}`;
const konfig = JSON5.parse(konfigJSON5);
// For project configuration files, consider YAML (see the YAML article)
// which is even more human-readable
The Safe JSON Workflow #
flowchart TD
A[Incoming Data\nAPI Response / File / Input] --> B[JSON.parse\nstring → unknown]
B --> C[Validate with Zod\nunknown → Known Type]
C --> D{Validation\nSuccessful?}
D -- Yes --> E[Use the Data\nwith Full Type Safety]
D -- No --> F[Handle the Error\nLog + Return Error / Throw]
G[TypeScript Data] --> H{Special Types?\nDate Map Set BigInt}
H -- Yes --> I[Replacer Function\nor toJSON Method]
H -- No --> J[JSON.stringify\nDirectly]
I --> J
J --> K[JSON String\nReady to Send / Store]
style C fill:#339af0,color:#fff
style D fill:#fcc419,color:#000
style E fill:#51cf66,color:#fff
style F fill:#ff6b6b,color:#fffSummary #
JSON.parsereturnsany— this disables all type checking; always validate theJSON.parseresult with Zod or a type guard before use, don’t just type-assert.JSON.stringifysilently removesundefined, functions, and symbols — properties with those values vanish without an error; always verify the output for objects containing these values.Datebecomes an ISO string afterJSON.stringify— andJSON.parsedoesn’t automatically restore it to aDate; use a reviver function or manual transformation for a correct roundtrip.MapandSetbecome{}after serialization — use custom replacers/revivers or convert to arrays before serialization if you need to preserve these structures.BigIntcauses aTypeErrorinJSON.stringify— use a replacer to convert it to a string first.- Define
toJSON()in classes to control serialization — this prevents sensitive properties (passwords, tokens) from accidentally appearing in JSON output.- Zod for schema validation —
z.infer<typeof Schema>generates TypeScript types automatically from a schema; usesafeParse()(notparse()) so errors can be handled without try-catch.- JSON Lines (JSONL) for large data — one JSON object per line enables streaming and incremental processing without loading the whole file into memory.
- Parsing JSON from files and APIs must always be validated — data from external sources can never be trusted for its type; schema validation is the last line of defense before data enters the system.