3MIKAN
仮想通貨直コン

JSON.stringifyで値が消えるのはなぜ?undefined・NaN・Mapと保存形式の決め方

JSONへ保存したらプロパティが消えた、Mapが空になった、Dateが文字列になった。JavaScriptの値とJSONの違いを実行例で確認し、未取得・ゼロ・大きな整数を区別する保存形式を考えます。

3MIKANのキャラクターが値の入った区画と空の区画を分け、荷ほどきした内容が元の意味を保つか確認する場面

取得結果をJSON.stringify()で保存し、読み戻したら項目が消えていた。エラーは出ていないのに、Mapが空のオブジェクトになり、日付の型も変わっている。こうした変化は、保存処理の不具合とは限らず、JSONへ変換する規則によって起きます。

この記事は、RPCの取得結果やアプリの状態をJavaScriptから保存する人向けです。文字列化に成功したことと、必要な情報を復元できることは別として、保存前後の値を確認します。

コードはNode.js v22.16.0で確認しました。使うのは手元の合成データだけで、外部サービスやアカウントは不要です。変換規則を変更するreplacerや独自のtoJSONがない状態を基本にします。

各節のJavaScriptを別のexample.mjsへ保存し、node example.mjsで実行できます。「上の関数へ続ける」とした箇所は、関数定義と呼び出しを同じファイルへ置きます。

エラーが出なくても、保存前と同じ内容にはならない

まず、未取得の数量、不正な計算結果、ラベルの対応表、確認時刻を一つのオブジェクトへ入れます。

const observation = {
  amount: undefined,
  ratio: NaN,
  labels: new Map([['network', 'sample']]),
  checkedAt: new Date('2026-01-01T00:00:00.000Z'),
};

const text = JSON.stringify(observation);
console.log(text);
const restored = JSON.parse(text);
console.log(Object.hasOwn(restored, 'amount'));
console.log(restored.checkedAt instanceof Date);

結果は次のとおりです。

{"ratio":null,"labels":{},"checkedAt":"2026-01-01T00:00:00.000Z"}
false
false

amountは省かれ、NaNnullへ変わりました。labelsの対応関係も残らず、日付は文字列として戻ってきます。[1][2]

ここで必要なのは、すべてを強引に元の型へ戻すことではありません。後で「未取得だった」「計算結果が有効でなかった」「どのラベルが付いていた」を判断したいなら、その情報を保存形式に含めることです。

undefinedは、置く場所によって扱いが違う

undefinedは、オブジェクトのプロパティでは省略され、配列の要素ではnullになります。最上位でそのまま渡したときは、JSON文字列ではなくJavaScriptのundefinedが返ります。[1]

console.log(JSON.stringify({ value: undefined }));
console.log(JSON.stringify([undefined]));
console.log(JSON.stringify(undefined) === undefined);
// {}
// [null]
// true

つまり、JSON.stringify()の戻り値が常に文字列であるとは限りません。戻り値をそのまま別の保存APIへ渡すコードでは、この点も確認します。

通常の変換で、間違えやすい値を表へまとめます。独自の変換関数やプロトタイプの変更によって挙動を変えていない場合の整理です。[1]

JavaScript側の値 オブジェクトの値として保存 配列の要素として保存
undefined そのプロパティを省略 null
関数・Symbolの値 そのプロパティを省略 null
NaNInfinity-Infinity null null
null null null
0false・空文字列 その値を残す その値を残す
BigInt TypeError TypeError

同じnullになっても、変換前の意味は同じとは限りません。変換後のJSONだけを見て、元がNaNだったのか、最初からnullだったのかを判別することはできません。

Mapは空かどうかではなく、保存されるプロパティを見る

追加のプロパティを持たない通常のMapは、値が入っていても{}へ変換されます。JSONへの標準変換が見るのは、列挙可能な自身の文字列キープロパティであり、Mapの内部の対応表ではないためです。[1][3]

文字列キーのラベル表なら、キーと値の組を配列へ取り出す方法があります。

const labels = new Map([['network', 'sample'], ['mode', 'offline']]);
const text = JSON.stringify({ labels: [...labels] });
const restored = new Map(JSON.parse(text).labels);

console.log(text);
console.log(restored.get('network'));
// {"labels":[["network","sample"],["mode","offline"]]}
// sample

この例は、自分で作った「文字列キーと文字列値の組」を読み戻しています。外部から受け取るJSONへそのまま適用するなら、配列の形、各組の長さ、キーと値の型、重複キーの方針を先に検査します。

また、オブジェクトをキーにするMapでは、文字列化だけで元のオブジェクトの同一性を保存できません。ラベルのようなデータへ変換する例と、任意のMapを完全復元する仕組みは区別してください。

Dateは文字列になり、BigIntはそのままでは例外になる

有効なDateは、通常、toJSON()によってUTCの日時文字列へ変わります。JSON.parse()は、その文字列を自動でDateに戻しません。不正な日時を持つDateでは、toJSON()nullを返します。[2]

一方、通常のBigIntはそのままではJSONへ変換できません。保存側で10進文字列と決め、読み取り側もその決め方へ合わせます。[1]

try {
  JSON.stringify({ blockNumber: 9_007_199_254_740_993n });
} catch (error) {
  console.log(error.name);
}

const saved = JSON.stringify({
  blockNumber: '9007199254740993',
  checkedAt: '2026-01-01T00:00:00.000Z',
});
const record = JSON.parse(saved);
console.log(typeof record.blockNumber, typeof record.checkedAt);
// TypeError
// string string

数字に見える文字列をすべて整数へ戻すと、識別子や先頭ゼロを持つコードまで変えてしまいます。日付に見える文字列も同様です。フィールドの名前と保存形式を決め、必要な場所だけ検証して変換する方針にします。

replacerを使えば万能、というわけでもありません。標準の処理では、toJSON()が先に呼ばれ、その結果がreplacerへ渡ります。value instanceof Dateだけで元の日付を検出しようとすると、すでに文字列になっている場合があります。[1][2]

let observedType;
JSON.stringify(
  { checkedAt: new Date('2026-01-01T00:00:00.000Z') },
  (key, value) => {
    if (key === 'checkedAt') observedType = typeof value;
    return value;
  },
);
console.log(observedType);
// string

未取得とゼロを、保存形式で明確に分ける

ここでは、小さなカウンターの読み取り結果を保存する形式を決めます。実際の残高取得やブロック取得ではなく、状態の保存方法を説明するための例です。

{"schemaVersion":1,"state":"ok","value":"0"}
{"schemaVersion":1,"state":"unavailable"}

成功ならvalueを非負整数の10進文字列で持ち、未取得なら値を持たせません。形式の版を表すschemaVersionも入れます。これはこの記事の設計例であり、JSONやEthereumの標準形式ではありません。

読み戻しでは、版、状態、値、余分なフィールドを検査します。次の関数は、この二つの形だけを受け入れます。

function readCounterSnapshot(text) {
  if (typeof text !== 'string' || text.length > 4096) {
    throw new TypeError('snapshot must be a short JSON string');
  }
  const record = JSON.parse(text);
  if (record === null || typeof record !== 'object' || Array.isArray(record)) {
    throw new TypeError('snapshot must be an object');
  }
  if (record.schemaVersion !== 1) throw new TypeError('unsupported version');

  const keys = Object.keys(record).sort().join(',');
  if (record.state === 'unavailable' && keys === 'schemaVersion,state') {
    return { state: 'unavailable' };
  }
  if (record.state !== 'ok' || keys !== 'schemaVersion,state,value') {
    throw new TypeError('invalid snapshot fields');
  }
  if (typeof record.value !== 'string' || record.value.length > 78) {
    throw new TypeError('value must be a bounded decimal string');
  }
  const match = /^(0|[1-9][0-9]*)$/.exec(record.value);
  if (!match || match[0] !== record.value) throw new TypeError('invalid integer');

  const value = BigInt(record.value);
  if (value > (1n << 256n) - 1n) throw new RangeError('value exceeds sample limit');
  return { state: 'ok', value };
}

入力文字列のlengthを4096以下とする条件、最大78桁、256ビットの上限は、このサンプルの入力方針です。lengthはUTF-16のコード単位で数えます。HTTP本文のバイト数制限や、保存先での容量制限を代わりに実施するものではありません。

末尾の改行、符号、指数表記、余分な先頭ゼロも許容していません。この正規表現はmフラグを使わず、入力全体を対象にしています。JavaScriptの$が行末にも一致するのはmを指定した場合なので、他言語の正規表現と混同しないでください。[6]

一致部分と入力全体の比較も残しています。将来フラグやパターンを変更した場合でも、文字列の一部分だけが一致した入力を受け入れない意図です。

上の関数へ、次を続けます。

const zero = readCounterSnapshot('{"schemaVersion":1,"state":"ok","value":"0"}');
const missing = readCounterSnapshot('{"schemaVersion":1,"state":"unavailable"}');
console.log(zero.state, zero.value === 0n);
console.log(missing.state, Object.hasOwn(missing, 'value'));
// ok true
// unavailable false

成功の0と未取得は、読み戻した後も異なる状態として残ります。okなのに値がない入力や、未取得なのに値が付いた入力は拒否します。

ただし、JSON.parse()後の検証には限界があります。同じキーがJSON本文に重複していれば、通常の解析後には最後の値が残り、ここでは重複自体を検出しません。重複キーを禁止する契約が必要な場合は、それを検出できる解析方法も必要です。[1]

また、schemaVersion: 1と書かれているだけで内容の正しさが保証されるわけではありません。版は「どの規則で読むか」を選ぶ入口で、各フィールドの検査を省略する印ではありません。

メモリー内のコピーなら、JSONを経由しない選択肢もある

別の変数として編集したいだけなら、文字列として保存する必要はないかもしれません。structuredClone()は、対応する型について構造化複製を行います。次の例ではBigIntMapDate、自身への参照を含めて複製します。[4][5]

const original = {
  count: 1n,
  labels: new Map([['mode', 'offline']]),
  checkedAt: new Date('2026-01-01T00:00:00.000Z'),
};
original.self = original;

const copy = structuredClone(original);
copy.labels.set('mode', 'edited');
console.log(copy.count === 1n, copy.checkedAt instanceof Date, copy.self === copy);
console.log(original.labels.get('mode'));
// true true true
// offline

これでJSON文字列を作ったことにはなりません。複製できた値を改めてJSON.stringify()すれば、BigIntなどの変換問題は残ります。

structuredClone()も、すべての値を複製する仕組みではありません。たとえば関数を含めるとDataCloneErrorになります。対応する型、転送の指定、独自クラスの扱いなどは、用途に応じて確認します。[4]

まとめ:保存後に必要な判断から逆算する

保存の前に、「後で何を区別したいか」を決めます。未取得とゼロ、日時の文字列と日時オブジェクト、対応表と普通のオブジェクトは、それぞれ別の意味です。

JSONへ変換するなら、フィールドの型、欠損の表現、大きな整数の表記、形式の版を決め、読み戻しも一緒に検証します。単なるメモリー内の複製なら、文字列化とは別の仕組みを選びます。

例外が出ないことだけでなく、保存前後で必要な情報が残ることを確認してください。

トークン数量の入力・計算・保存を一緒に設計する場合は、BigIntとdecimalsを使う数量計算へ進めます。日時の単位やタイムゾーンを取り違えている場合は、秒・ミリ秒・日本時間の区別も確認してください。

一次情報

確認日:2026年9月6日。実行環境はNode.js v22.16.0です。

[1] ECMAScript 2025 — JSON.parse / JSON.stringifyと内部の変換規則

[2] ECMAScript 2025 — Date.prototype.toJSON

[3] ECMAScript 2025 — Map Objects

[4] WHATWG HTML — Structured cloning

[5] Node.js 22.16.0 — structuredClone

[6] ECMAScript 2025 — Assertions

確認した一次情報