🫠

JSON.parse() / JSON.stringify() を使用してハマった3つのポイントと解決策

に公開
2026/02/04

初めに

JSON.parse() / JSON.stringify()を使用してハマった3つのポイントについてまとめてみたいと思います。この記事は、以下について書いてます。

  • JSON.parse()の戻り値は any
  • Date がstringになる問題
  • JSON.stringify()は循環参照で落ちる問題

今回のバージョン

  "typescript": "^5.9.3",
  "zod": "^4.3.6"

JSON.parse() の戻り値は any

まず、以下のコードを見てください。

type User = {
  id: number;
  name: string;
};

const json = '{"id":1,"name":"Taro"}';
const user = JSON.parse(json);

user.id.toFixed(); 

一見、普通に見えるこのコード何がやばそうかわかりますか?

{
  "id": "1",
  "name": "Taro"
}

上記のようにレスポンスが変わった場合本番でエラーになってしまいます。
以下のように型が以下のようになるのが原因です。

解決方法

手書き型ガード(User-defined Type Guards)は正直つらいし、しんどいので、今回はZodを使って解決していきます。

import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
});

//スキーマから型を生成する
type User = z.infer<typeof UserSchema>;

const json = '{"id":1,"name":"Taro"}';

const parsed = JSON.parse(json);

//zod で検証する
const user = UserSchema.parse(parsed);


user.id.toFixed();
console.log(user.name);

実行結果
Taro
不正な JSON が来た(const json = '{"id":"abc","name":123}';)場合は、ZodErrorになり、早い段階で異常に気づくことができ、ログ・エラーハンドリングがしやすいという利点があります。

try/catch を含めたパターン

import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
});

type User = z.infer<typeof UserSchema>;


//const json = '{"id":"abc","name":123}'; // エラーパターン
const json = '{"id":1,"name":"Taro"}';

function parseUser(json: string): User {
  const parsed = JSON.parse(json);
  return UserSchema.parse(parsed);
}

try {
  const user = parseUser(json);
  user.id.toFixed();
  console.log(user.name);
} catch (e) {
  console.error("不正なUserデータです", e);
}

実行結果
Taro
const json = '{"id":"abc","name":123}';の場合は、不正なUserデータです と出ます。

Date が string になる問題

結論から言うと、JSONにDate型は定義されてないです。詳しくはこちらを見てください。 そのため、JSON.stringify()するとDateは stringになり、JSON.parse()してもDateには戻らないです。TypeScriptの型があっても自動では復元されません。この仕様を知らないと、日時を扱う際に簡単に壊れてしまいます。

const user = {
  id: 1,
  lastLogin: new Date(),
};

const json = JSON.stringify(user);
console.log(json);

上記は、一見、問題なさそうに見えるコードですが、実行すると結果は以下のようになります。
{"id":1,"lastLogin":"2026-01-22T10:00:00.000Z"}そして、 lastLoginは すでに stringになっている。そして、JSON.parse()しても Date には戻らないです。以下がその例です。

const user = {
  id: 1,
  lastLogin: new Date(),
};

const json = JSON.stringify(user);
//console.log(json);
const parsed = JSON.parse(json);

parsed.lastLogin instanceof Date; // false
typeof parsed.lastLogin; // "string"

console.log(parsed.lastLogin)

これも実行結果は文字列です。なので、parsed.lastLogin.getTime();を実行してもparsed.lastLogin.getTime is not a functionとなりエラーとなります。
解決方法は2つあると考えてます。

解決方法その1

z.coerce.date() を使うパターンです。

import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  lastLogin: z.coerce.date(), // ここがポイント
});

type User = z.infer<typeof UserSchema>;

// テスト用データ
const json = '{"id":1,"lastLogin":"2026-01-22T10:00:00.000Z"}';
//const json = '{"id":1,"lastLogin":"invalid-date"}'; エラーパターン

function parseUser(json: string): User {
  const parsed = JSON.parse(json);
  return UserSchema.parse(parsed);
}

try {
  const user = parseUser(json);
  
  console.log(user.lastLogin instanceof Date); // true
  console.log(user.lastLogin.toISOString());   // 2026-01-22T10:00:00.000Z
} catch (e) {
  console.error("不正なUserデータです", e);
}

実行結果
true
2026-01-22T10:00:00.000Z

ちなみに不正なデータだと ZodErrorになります。

解決方法その2

JSON.parse() の reviver を使います。JSON.parse() には reviver という第2引数があり、
parse の途中で値を変換できます。JSON.parse() の reviver で値を復元し、最後に Zod で検証するパターンです。

import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  lastLogin: z.date(), 
});

type User = z.infer<typeof UserSchema>;

function reviver(key: string, value: unknown) {
  if (key === "lastLogin" && typeof value === "string") {
    const date = new Date(value);
    return isNaN(date.getTime()) ? value : date;
  }
  return value;
}

// テスト用データ
const json = '{"id":1,"lastLogin":"2026-01-22T10:00:00.000Z"}';
//const json = '{"id":1,"lastLogin":"invalid-date"}'; // エラーパターン

function parseUser(json: string): User {
  const parsed = JSON.parse(json, reviver);
  return UserSchema.parse(parsed); 
}

try {
  const user = parseUser(json);

  console.log(user.lastLogin instanceof Date); // true
  user.lastLogin.getTime();                    // 型安全にメソッドを呼べる
} catch (e) {
  console.error("不正なUserデータです", e);
}

実行結果
true
ちなみに、不正なデータだとZodErrorとなります。

JSON.stringify()は循環参照で落ちる問題

JSON.stringify()は循環参照を含むオブジェクトを扱えないです。

type User = {
  id: number;
  self?: User;
};

const user: User = {
  id: 1,
};

user.self = user;

/**
 * ここで例外が発生する
 */
JSON.stringify(user);

実行結果は、Converting circular structure to JSONとなりエラーになります。

解決方法

これはいろんな解決方法があると思ってます。replacer引数を使って、一度見たオブジェクトをスキップする方法や専用ライブラリを使う方法などです。ここでは今回は、設計で循環参照を断ち切る 方法を紹介します。つまり、「stringify で落ちるから対策する」のではなく、「JSONにするデータ構造に循環参照を含ませない」 ということです。ここでは3つ方法を紹介します。

方法その1 ID参照に置き換える

type User = {
  id: number;
  postIds: number[];
};

type Post = {
  id: number;
  authorId: number;
};

このように参照は ID だけにするという方法です。

方法その2 DTO(JSON用構造)を別に作る

type User = {
  id: number;
  posts: Post[];
};

type UserDTO = {
  id: number;
  postIds: number[];
};

このようにDTO(JSON用構造)を別に作り、JSON.stringify() は DTO に対してだけ使うという方法です。

方法その3 片方向だけを JSON に含める

type PostDTO = {
  id: number;
  author: {
    id: number;
    name: string;
  };
};

片方向だけを JSON に含め、「戻れない」構造にするという方法です。

まとめ

1つ目の問題

  • JSON.parse() は、anyを返す
  • TypeScriptはそれを守ってくれない
  • zod を使えば「検証」と「型付け」を同時に解決できる
  • 境界で validate する。

2つ目の問題

  • JSONに Date は存在しない
  • stringify 時点で string になる
  • parse しても自動では戻らない
  • reviver で復元できる
  • z.coerce.date() が簡潔

3つ目の問題

  • JSON.stringify() は循環参照で落ちる
  • 回避ではなく 設計で断ち切りたい

Discussion