JavaScriptとNode.jsのJSON実践ガイド:型の変換から安全なデータの受け渡しまで

WebアプリケーションやAPI通信、設定ファイルの管理において、JSON(JavaScript Object Notation)はデファクトスタンダードとして日常的に利用されています。JavaScriptのオブジェクトリテラルと見た目が酷似しているため直感的に扱える反面、言語内の型体系とJSON仕様のデータモデルの間には明確な差異が存在します。

この差異を意識せずに扱っていると、意図しないプロパティの欠落、数値の丸めによる精度落ち、予期しない例外の発生といったトラブルに直面します。本稿では、RFC 8259およびECMAScript仕様に基づきながら、JavaScriptおよびNode.js環境におけるJSONのシリアライズ・デシリアライズの仕組み、内部挙動、エッジケース、そしてファイル入出力時の注意点までを検証結果とともに深掘りします。


1. JSON仕様とJavaScriptの型体系のギャップ

1.1 RFC 8259の定義と値型

IETFによる規格 RFC 8259 では、JSONを軽量でテキストベース、かつ言語非依存なデータ交換フォーマットと規定しており、ECMAScriptプログラミング言語標準から派生したと説明されています。

RFC 8259で定義されているJSONの値(Value)型は、以下の6種類に限られます。

  1. object(キーと値のペアの順序なしコレクション)
  2. array(値の順序付きリスト)
  3. number(10進表記の数値)
  4. string(Unicode文字列)
  5. true / false(真偽値リテラル)
  6. null(ヌルリテラル)

JavaScriptで扱う値には、undefined、関数、Symbol、BigInt などもあります。関数は呼び出し可能なオブジェクトであり、ここでは値の性質に応じて分けて説明します。JSONにはこれらをそのまま表す専用の型がありません。ECMAScriptのJSON仕様と、実際に変換した結果を対応づけて見ていきます。

1.2 数値(Number)の扱いの違い

RFC 8259の文法定義における数値(number)には、JavaScriptでおなじみの NaN(Not a Number)や Infinity(無限大)は定義されていません。

後述の検証でも示す通り、JSON.parse('{"a": NaN}') や JSON.parse('{"a": Infinity}') を実行すると構文エラー(SyntaxError)となります。また、JSON.stringify で NaN や Infinity をシリアライズしようとすると、例外をスローするのではなく null に置き換わって出力されます。

1.3 オブジェクトのキーと重複キー

RFC 8259の第4節は、名前を文字列とし、同じオブジェクト内では名前を一意にすることを推奨しています。重複した名前を受け取ると、最後の値だけを残す、エラーにする、重複も含めて返すなど、受信側によって結果が変わり得ると説明しています。送信側で名前の重複を避け、特定のパーサーの処理結果に依存しないデータを作る方針が適しています。


2. 実機検証環境とテスト概要

本稿で解説するコード例および挙動の検証は、以下のサンドボックス環境で実施しました。

  • Node.js バージョン: v22.23.1
  • ネットワークモード: none(外部通信なし)
  • 実行形式: ECMAScript Modules (.mjs)
Node runtime: v22.23.1

3. JSON.stringifyの変換挙動とエッジケース

JSON.stringify は、ECMAScriptのStructured Dataセクションに定義されているアルゴリズムに従ってJavaScriptの値をJSONテキストにシリアライズします。値の型やコンテキスト(オブジェクトのプロパティか、配列の要素か、トップレベルか)によって、その挙動は大きく分岐します。

3.1 undefined、Function、Symbolの脱落とnull化

JavaScript特有の型である undefined、関数(Function)、シンボル(Symbol)は、JSONの6つの値型に対応しないため、以下のような特別な変換規則が適用されます。

  1. オブジェクトのプロパティ値の場合: そのプロパティ自体がシリアライズ結果から省略されます。
  2. トップレベルの値の場合: 文字列ではなくJavaScriptの undefined を返します。
  3. 配列の要素の場合: 添字の整合性を保つため、null に変換されます。

以下は実行した検証スクリプトの抜粋です。途中のケースやコメントを省略した箇所があります。assertを使うファイルの先頭では import assert from "node:assert/strict" を読み込んでいます。抜粋中の変数は同じ節の前のコードから引き継ぐ場合があるため、各断片を単独の完成したプログラムとは扱わないでください。

// A-1: object property with undefined value is omitted
const objUndef = { a: 1, b: undefined };
const r1 = JSON.stringify(objUndef);
console.log('A-1 obj undefined omit:', r1);
assert.equal(r1, '{"a":1}');

// A-4: top-level undefined returns undefined (not string)
const r4 = JSON.stringify(undefined);
console.log('A-4 top-level undefined:', r4);
assert.equal(r4, undefined);

// A-7: array element undefined -> null
const arrUndef = [1, undefined, 3];
const r7 = JSON.stringify(arrUndef);
console.log('A-7 array undefined->null:', r7);
assert.equal(r7, '[1,null,3]');

実行結果は以下のとおりです。

A-1 obj undefined omit: {"a":1}
A-2 obj function omit: {"a":1}
A-3 obj symbol omit: {"a":1}
A-4 top-level undefined: undefined
A-5 top-level function: undefined
A-6 top-level symbol: undefined
A-7 array undefined->null: [1,null,3]
A-8 array function->null: [1,null,3]
A-9 array symbol->null: [1,null,3]

オブジェクトのプロパティではキーごと削除されるのに対し、配列内では要素数が変わらないように null で埋められる点が重要なトレードオフです。

3.2 NaNとInfinityのシリアライズ

前述のとおり、JSONでは NaN や Infinity は許可されていません。JSON.stringify にこれらを渡すと、例外を出さずに null にシリアライズされます。

// B-1: NaN -> null
const r1 = JSON.stringify(NaN);
console.log('B-1 NaN->null:', r1);
assert.equal(r1, 'null');

// B-2: Infinity -> null
const r2 = JSON.stringify(Infinity);
console.log('B-2 Infinity->null:', r2);
assert.equal(r2, 'null');

// B-4: inside object
const r4 = JSON.stringify({ a: NaN, b: Infinity });
console.log('B-4 obj NaN/Inf->null:', r4);
assert.equal(r4, '{"a":null,"b":null}');

実行結果:

B-1 NaN->null: null
B-2 Infinity->null: null
B-3 -Infinity->null: null
B-4 obj NaN/Inf->null: {"a":null,"b":null}
B-5 arr NaN/Inf->null: [null,null,null]

数理計算の結果として発生した NaN や無限大が、シリアライズを経て null に化けるため、受信側で「値が存在しない」と誤認されるリスクに留意する必要があります。

3.3 BigIntによるTypeError

標準の JSON.stringify に変換用の処理を指定せず BigInt を渡すと、TypeError が発生します。以下では toJSON の追加や replacer による事前変換をしていない場合を確認しています。

// C-1: top-level BigInt
try {
  JSON.stringify(42n);
  assert.fail('should have thrown');
} catch(e) {
  console.log('C-1 BigInt top-level throws:', e.constructor.name);
  assert.ok(e instanceof TypeError, `expected TypeError, got ${e.constructor.name}`);
}

実行結果:

C-1 BigInt top-level throws: TypeError
C-2 BigInt in object throws: TypeError
C-3 BigInt in array throws: TypeError

この検証条件では、トップレベル、オブジェクト内部、配列内部のいずれでも TypeError が発生しました。データベースの64ビット整数IDなどを扱う際は、後述する文字列変換やカスタムの toJSON メソッドによる対策が必須となります。

3.4 循環参照(Circular Reference)の検出

オブジェクトが自分自身や祖先ノードを参照している場合、シリアライズ処理が無限ループに陥るのを防ぐため、JavaScriptエンジンは構造を検知して TypeError をスローします。

// D-1: direct circular
const obj = { a: 1 };
obj.self = obj;
try {
  JSON.stringify(obj);
  assert.fail('should have thrown');
} catch(e) {
  console.log('D-1 circular throws:', e.constructor.name);
  assert.ok(e instanceof TypeError);
}

実行結果:

D-1 circular throws: TypeError
D-2 indirect circular throws: TypeError

親子関係を持つ木構造のデータやDOM/ORMモデルなどをJSON化する際には、事前に循環参照を解消するか、必要なプロパティのみを抽出する設計が求められます。

3.5 疎配列(Sparse Array)の処理

要素が飛び飛びになっている疎配列([1, , 3] など)をシリアライズした場合、空きスロット(hole)は null として出力されます。

// E-1: sparse array with hole
const arr = [1, , 3];
console.log('arr length:', arr.length, 'arr[1]:', arr[1]);
const r1 = JSON.stringify(arr);
console.log('E-1 sparse array:', r1);
assert.equal(r1, '[1,null,3]');

実行結果:

arr length: 3 arr[1]: undefined
E-1 sparse array: [1,null,3]
E-2 new Array holes: ["a",null,"c"]

4. DateオブジェクトとtoJSONメソッドの協調

4.1 Date.prototype.toJSON

JavaScript標準の組み込みオブジェクトである Date には、プロトタイプ上に toJSON メソッドが定義されています。

const d = new Date('2024-01-15T12:00:00.000Z');
assert.equal(typeof d.toJSON, 'function', 'Date has toJSON');
console.log('F-1 Date.toJSON exists:', typeof d.toJSON);

const iso = d.toJSON();
console.log('F-2 toJSON():', iso);
assert.equal(iso, '2024-01-15T12:00:00.000Z');

const r3 = JSON.stringify(d);
console.log('F-3 stringify Date:', r3);
assert.equal(r3, '"2024-01-15T12:00:00.000Z"');

実行結果:

F-1 Date.toJSON exists: function
F-2 toJSON(): 2024-01-15T12:00:00.000Z
F-3 stringify Date: "2024-01-15T12:00:00.000Z"
F-4 Date in obj: {"ts":"2024-01-15T12:00:00.000Z"}

JSON.stringify は、シリアライズ対象のオブジェクトが toJSON メソッドを持っている場合、まずそのメソッドを呼び出し、その戻り値をシリアライズします。今回のように有効な日時を持つ Date の場合、toISOString() と同じ日時文字列が返されるため、結果としてダブルクォートで囲まれた日時文字列が出力されます。

4.2 独自オブジェクトでのtoJSONの実装

この仕組みは、ユーザー定義のクラスやオブジェクトでも活用できます。

// L-1: custom toJSON on plain object
const obj1 = {
  internal: 'hidden',
  public: 'visible',
  toJSON() { return { public: this.public }; }
};
const r1 = JSON.stringify(obj1);
console.log('L-1 custom toJSON:', r1);
assert.equal(r1, '{"public":"visible"}');

// L-2: toJSON receives the key it is serialized under
const obj2 = {
  value: 42,
  toJSON(key) { return `key=${key},val=${this.value}`; }
};
const r2 = JSON.stringify({ nested: obj2 });
console.log('L-2 toJSON key arg:', r2);
assert.equal(r2, '{"nested":"key=nested,val=42"}');

実行結果:

L-1 custom toJSON: {"public":"visible"}
L-2 toJSON key arg: {"nested":"key=nested,val=42"}
L-3 replacer after toJSON: {"a":1,"b":"added_by_toJSON"}

toJSON メソッドには、自身がシリアライズされているプロパティのキー名(トップレベルの場合は空文字列 "")が引数として渡されます。内部の秘密情報を除外したり、複合データモデルをフラットなオブジェクトに変換して出力したりする際に有用です。


5. JSON.stringifyの高度な引数(replacer と space)

JSON.stringify は第2引数に replacer、第3引数に space を受け取ることができます。

引数の指定順は次のとおりです。角括弧は省略可能な引数を表す説明用の記法で、実行するコードではありません。

JSON.stringify(value[, replacer[, space]])

5.1 replacer(関数または配列)

関数によるフィルタリング・変換

関数を指定した場合、オブジェクトツリーの各キー・値ペアに対してその関数が再帰的に呼び出されます。トップレベルではキーとして空文字列 "" が渡されます。オブジェクトのプロパティに対して undefined を返すと、そのプロパティは出力から省略されます。配列要素では null となり、ルートの値に対して返すと JSON.stringify 全体の戻り値が undefined になります。

const obj = { a: 1, b: 'hello', c: true, d: null };

// G-1: replacer function omits key b
const r1 = JSON.stringify(obj, (key, val) => key === 'b' ? undefined : val);
console.log('G-1 replacer omit b:', r1);
assert.equal(r1, '{"a":1,"c":true,"d":null}');

実行結果:

G-1 replacer omit b: {"a":1,"c":true,"d":null}

配列によるホワイトリスト指定

文字列または数値の配列を渡した場合、オブジェクトでは、配列に含まれる名前のプロパティが出力対象になります。この指定は配列の要素番号を選ぶための一覧ではありません。以下の実験は文字列の名前を使ったオブジェクトの例です。

// G-2: replacer array - only include a and c
const r2 = JSON.stringify(obj, ['a', 'c']);
console.log('G-2 replacer array [a,c]:', r2);
assert.equal(r2, '{"a":1,"c":true}');

実行結果:

G-2 replacer array [a,c]: {"a":1,"c":true}

5.2 toJSONとreplacerの評価順序

オブジェクトが toJSON メソッドを持ち、かつ JSON.stringify の第2引数に replacer が指定されている場合、まず toJSON メソッドが実行され、その戻り値に対して replacer が適用されます。

const calls = [];
const obj = {
  x: 10,
  toJSON() {
    calls.push('toJSON');
    return { x: this.x, added: 'fromToJSON' };
  }
};

const replacer = (key, val) => {
  calls.push(`replacer:${key}`);
  return val;
};

const result = JSON.stringify(obj, replacer);
console.log('N-1 call order:', calls);
console.log('N-1 result:', result);

assert.equal(calls[0], 'toJSON');
assert.equal(calls[1], 'replacer:');
assert.ok(calls.some(c => c === 'replacer:added'), 'replacer sees added from toJSON');

実行結果:

N-1 call order: [ 'toJSON', 'replacer:', 'replacer:x', 'replacer:added' ]
N-1 result: {"x":10,"added":"fromToJSON"}
N-2 replacer array after toJSON: {"a":1,"c":3}

ログから明らかなように、toJSON が先行して呼び出され、toJSON によって追加されたプロパティ added も後続の replacer の呼び出し対象に含まれていることが分かります。

5.3 spaceによる整形と上限クランプ

第3引数の space に数値を渡すと、出力されるJSONが改行および指定した文字数のインデントで整形されます。この数値の有効範囲は最大10であり、10を超える数値を渡しても自動的に10に切り詰め(クランプ)されます。

// G-4: space = 10 (max)
const r4 = JSON.stringify({ x: 1 }, null, 10);
const lines4 = r4.split('\n');
console.log('G-4 space=10 line2 leading spaces:', lines4[1].match(/^ +/)?.[0].length);
assert.equal(lines4[1].match(/^ +/)?.[0].length, 10);

// G-5: space = 11 should be clamped to 10
const r5 = JSON.stringify({ x: 1 }, null, 11);
const lines5 = r5.split('\n');
console.log('G-5 space=11 clamped to 10, leading spaces:', lines5[1].match(/^ +/)?.[0].length);
assert.equal(lines5[1].match(/^ +/)?.[0].length, 10);

実行結果:

G-4 space=10 line2 leading spaces: 10
G-5 space=11 clamped to 10, leading spaces: 10
G-6 space=tab: "{\n\t\"x\": 1\n}"

6. JSON.parseの構文検査とreviver

6.1 不正なJSONとSyntaxError

JSON.parse は厳密な構文解析を行います。JavaScriptのリテラルとしては許容される表現(単一引用符、末尾のカンマ、プロパティ名のクォート省略、undefined や NaN の埋め込みなど)も、JSON仕様に合致しない場合はすべて SyntaxError になります。

const badInputs = [
  '{bad}',
  '{"a": undefined}',
  'undefined',
  "{'a': 1}",
  '{"a": NaN}',
  '{"a": Infinity}',
  '',
  '[1, 2,]',
];

badInputs.forEach((input, i) => {
  try {
    JSON.parse(input);
    assert.fail(`H-${i+1} should have thrown for input: ${input}`);
  } catch(e) {
    console.log(`H-${i+1} SyntaxError for [${input}]:`, e.constructor.name);
    assert.ok(e instanceof SyntaxError, `expected SyntaxError, got ${e.constructor.name}`);
  }
});

実行結果:

H-1 SyntaxError for [{bad}]: SyntaxError
H-2 SyntaxError for [{"a": undefined}]: SyntaxError
H-3 SyntaxError for [undefined]: SyntaxError
H-4 SyntaxError for [{'a': 1}]: SyntaxError
H-5 SyntaxError for [{"a": NaN}]: SyntaxError
H-6 SyntaxError for [{"a": Infinity}]: SyntaxError
H-7 SyntaxError for []: SyntaxError
H-8 SyntaxError for [[1, 2,]]: SyntaxError
H-9 valid JSON parse: {"a":1,"b":"hello","c":null,"d":true}

6.2 reviver引数による復元と削除

JSON.parse の第2引数である reviver 関数を使用すると、構文解析されたオブジェクトツリーを葉(leaf)から根(root)へボトムアップに巡回しながら値を変換できます。

  1. 特定フォーマットの復元: ISO 8601文字列を検知して Date インスタンスへ変換する。
  2. キーの削除: reviver 関数が undefined を返すと、そのプロパティは親オブジェクトから削除されます。
// I-1: reviver converts ISO string to Date
const isoReviver = (key, val) => {
  if (typeof val === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(val)) {
    return new Date(val);
  }
  return val;
};
const r1 = JSON.parse('{"ts":"2024-01-15T12:00:00.000Z","name":"test"}', isoReviver);
console.log('I-1 reviver Date:', r1.ts instanceof Date, r1.ts.toISOString());
assert.ok(r1.ts instanceof Date);

// I-2: reviver returning undefined deletes the key
const deleteReviver = (key, val) => {
  if (key === 'secret') return undefined;
  return val;
};
const r2 = JSON.parse('{"a":1,"secret":"pwd","b":2}', deleteReviver);
console.log('I-2 reviver delete key:', JSON.stringify(r2));
assert.equal(JSON.stringify(r2), '{"a":1,"b":2}');
assert.ok(!('secret' in r2));

実行結果:

I-1 reviver Date: true 2024-01-15T12:00:00.000Z
I-2 reviver delete key: {"a":1,"b":2}
I-3 reviver order (b before a before ""): [ 'b', 'a', '' ]
I-4 reviver bigint id: 9007199254740993n bigint

この日時変換の正規表現は、先頭が日時らしい文字列を拾う例であり、厳密な入力検証器ではありません。実際の入力では、変換対象のフィールドを決め、許可する形式や無効な日時の扱いも定義します。ここで使った固定の日時文字列が変換できたことと、任意の入力を安全に受け付けられることは別です。

巡回順序(I-3)を確認すると、ネストされた内側のプロパティ b、外側の a、そしてルートである空文字列 "" という順で呼び出されていることが分かります。


7. 数値の精度限界:IEEE 754と巨大な整数

7.1 倍精度浮動小数点数の限界

JavaScriptの Number 型は、IEEE 754倍精度浮動小数点数(64ビット)として実装されています。そのため、正確に表現できる整数の範囲には上限が存在します。

ECMAScript仕様で定義されている Number.MAX_SAFE_INTEGER は 2**53 - 1(9007199254740991)です。この範囲を超えると、隣り合う整数をすべて区別できる保証がなくなります。範囲外の整数が必ず丸められるわけではなく、次のログにある 9007199254740992(2の53乗)は正確に表現できます。一方、その次の 9007199254740993 は同じ値へ丸められます。

// J-1: Number.MAX_SAFE_INTEGER
console.log('J-1 MAX_SAFE_INTEGER:', Number.MAX_SAFE_INTEGER);
assert.equal(Number.MAX_SAFE_INTEGER, 9007199254740991);

// J-4: large integer in JSON string loses precision on parse
const largeIdStr = '9007199254740993';
const parsedLarge = JSON.parse(largeIdStr);
console.log('J-4 parse large int string:', parsedLarge, 'type:', typeof parsedLarge);
console.log('J-4 parsedLarge === 9007199254740992:', parsedLarge === 9007199254740992);
assert.equal(typeof parsedLarge, 'number');
assert.equal(parsedLarge, 9007199254740992);
assert.notEqual(BigInt(parsedLarge), 9007199254740993n);

実行結果:

J-1 MAX_SAFE_INTEGER: 9007199254740991
J-2 2^53 parse: 9007199254740992
J-3 bigId stringify: 9007199254740992
J-3 literal already rounded: 9007199254740992 9007199254740992 true
J-4 parse large int string: 9007199254740992 type: number
J-4 parsedLarge === 9007199254740992: true
J-5 string-encoded large ID: 9007199254740993 string

7.2 実装間の相互運用性とベストプラクティス

上記の結果が示す通り、文字列 '9007199254740993' を JSON.parse すると、丸めが発生して 9007199254740992 に化けてしまいます。

RFC 8259の第6節は、広く使われるIEEE 754 binary64の範囲と精度を超える値で、相互運用上の問題が起こり得ると説明しています。また、整数については -(2**53)+1 から (2**53)-1 の範囲で数値が正確に一致するとしています。「15桁を超えたら一律に使えない」という制限ではありません。

安全な整数範囲を超える可能性のある64ビット整数IDを扱う場合は、JSON内では数値リテラルではなく文字列として伝送し、JavaScript側で BigInt(val) として復元するのが安全な設計パターンです。

// J-5: string-encoded ID avoids precision loss
const safeStr = '"9007199254740993"';
const parsedSafe = JSON.parse(safeStr);
console.log('J-5 string-encoded large ID:', parsedSafe, typeof parsedSafe);
assert.equal(parsedSafe, '9007199254740993');
assert.equal(typeof parsedSafe, 'string');

8. MapとSetのシリアライズにおける落とし穴

コレクションを扱う Map と Set は、JSON.stringify にそのまま渡しても意図したデータが出力されません。

変換用のフックや replacer 配列を指定しない標準的なオブジェクト変換では、JSON.stringify は自身が持つ列挙可能な文字列キーのプロパティを対象にします。Map や Set の内部要素はプロパティではなく内部スロットに保持されているため、シリアライズすると空のオブジェクト {} として出力されてしまいます。

// K-1: Map stringifies as {}
const map = new Map([['a', 1], ['b', 2]]);
const r1 = JSON.stringify(map);
console.log('K-1 Map stringify:', r1);
assert.equal(r1, '{}');

// K-2: Set stringifies as {}
const set = new Set([1, 2, 3]);
const r2 = JSON.stringify(set);
console.log('K-2 Set stringify:', r2);
assert.equal(r2, '{}');

実行結果:

K-1 Map stringify: {}
K-2 Set stringify: {}
K-3 Map->entries: [["a",1],["b",2]]
K-4 Map->Object: {"a":1,"b":2}
K-5 Set->Array: [1,2,3]
K-6 Map roundtrip a: 1 b: 2

8.1 明示的な変換パターン

今回のような Map や Set の要素をJSONに残すには、配列やプレーンなオブジェクトへ変換する方法があります。

// K-3: explicit Map -> array of entries conversion
const mapConverted = JSON.stringify([...map.entries()]);

// K-4: explicit Map -> Object.fromEntries
const mapObj = JSON.stringify(Object.fromEntries(map));

// K-5: explicit Set -> Array.from conversion
const setConverted = JSON.stringify(Array.from(set));

復元時も同様に、new Map(JSON.parse(mapJson)) や new Set(JSON.parse(setJson)) を明示的に行います。


9. Node.jsにおけるJSONファイルの入出力(fsモジュール)

Node.jsの公式ドキュメント(Node.js File system API)に記載されている通り、fs モジュールは主に3種類のスタイルを提供しています。

  1. 同期API(readFileSync, writeFileSync など)
  2. コールバックAPI(fs.readFile, fs.writeFile など)
  3. Promise API(fs/promises の readFile, writeFile など)

9.1 同期API(readFileSync / writeFileSync)での読み書き

CLIツールやサーバー起動時の初期化処理など、イベントループのブロックが問題にならない文脈では同期APIが手軽です。ファイル読み書き時にはオプション { encoding: 'utf8' } を指定することで、バッファ(Buffer)ではなく文字列として直接データを取得・出力できます。

const tmpPath = join(tmpdir(), 'test_json_exp.json');
const data = { name: 'test', count: 42, active: true, tags: ['a', 'b'] };

// M-1: sync write and read
fs.writeFileSync(tmpPath, JSON.stringify(data), { encoding: 'utf8' });
const rawSync = fs.readFileSync(tmpPath, { encoding: 'utf8' });
const parsedSync = JSON.parse(rawSync);
console.log('M-1 sync roundtrip:', JSON.stringify(parsedSync));
assert.deepEqual(parsedSync, data);

// M-2: sync write with pretty print
const prettyPath = join(tmpdir(), 'test_json_pretty.json');
fs.writeFileSync(prettyPath, JSON.stringify(data, null, 2), { encoding: 'utf8' });
const rawPretty = fs.readFileSync(prettyPath, { encoding: 'utf8' });
console.log('M-2 pretty written (first 20 chars):', rawPretty.substring(0, 20));

9.2 Promise API(fs/promises)での非同期読み書き

Webサーバーのリクエスト処理中など、同時実行性が重視される環境では fs/promises を利用します。

// M-4: promise-based read/write
await writeFile(tmpPath, JSON.stringify({ async: true, val: 99 }), 'utf8');
const rawAsync = await readFile(tmpPath, 'utf8');
const parsedAsync = JSON.parse(rawAsync);
console.log('M-4 async roundtrip:', JSON.stringify(parsedAsync));
assert.deepEqual(parsedAsync, { async: true, val: 99 });

実行結果:

M-1 sync roundtrip: {"name":"test","count":42,"active":true,"tags":["a","b"]}
M-2 pretty written (first 20 chars): {
  "name": "test",
M-3 fs.readFileSync type: function
M-3 fs.writeFileSync type: function
M-3 readFile (promise) type: function
M-3 writeFile (promise) type: function
M-4 async roundtrip: {"async":true,"val":99}
M-5 fs.readFile (callback) type: function

10. 安全なデータ受け渡しと運用上のトレードオフ

ここまでの挙動を踏まえ、実務で安全にJSONをやり取りするための設計指針を整理します。

10.1 シリアライズ時・デシリアライズ時の落とし穴まとめ

次の表は独自の変換フックを追加しない場合の整理です。Dateは今回検証した有効な日時、MapとSetは追加の独自プロパティを持たない例を対象にしています。

JavaScriptの型 / 状態 オブジェクトプロパティ時 配列要素時 トップレベル時 復元(parse)時の挙動
undefined プロパティごと省略 null undefined 省略された値は復元不可。配列内ではnullが残る
Function プロパティごと省略 null undefined 省略された値は復元不可。配列内ではnullが残る
Symbol プロパティごと省略 null undefined 省略された値は復元不可。配列内ではnullが残る
NaN / Infinity null null null 数値ではなく null として復元される
BigInt TypeError スロー TypeError スロー TypeError スロー 数値の場合は丸めリスク、文字列なら安全
循環参照 TypeError スロー TypeError スロー TypeError スロー -
疎配列の空き - null - null の要素として復元される
Date ISO 8601文字列 ISO 8601文字列 ISO 8601文字列 文字列として復元(Dateには戻らない)
Map / Set {} {} {} プレーンな空オブジェクトとして復元される

10.2 プロダクションでの実装パターン

  1. スキーマバリデーションの導入: JSON.parse が成功しても、必要な項目や値の範囲がアプリケーションの期待に合うとは限りません。構文エラーは SyntaxError として処理し、その後で項目の有無、型、範囲を別に検査します。スキーマ検証ライブラリを使う場合も、業務上の制約を定義する必要があります。本稿では特定のライブラリを比較検証していません。

  2. 型安全な日付の復元: reviver 引数を利用するか、スキーマバリデーションライブラリの変換(Coercion / Transform)パイプラインを利用して、ISO 8601文字列から明示的に Date オブジェクトへ復元します。

  3. 64ビットIDの文字列化: 安全な整数範囲を超える可能性のあるIDは、JSON上では文字列にする契約を送受信側で共有します。計算が不要なら、受信後も文字列のまま扱えます。BigInt に戻すのは整数として処理する必要がある場合です。UUIDやULIDは64ビット整数と同じ分類ではなく、それぞれの文字列表現を保って扱います。


確認できなかったこと

検証はNode.js v22.23.1と、本文に示した小さな固定入力を対象にしています。 コードとログは掲載箇所を照合しましたが、あらゆる入力や実行環境で同じ結果になることを網羅的に証明したものではありません。

  • 仕様と実装の対応範囲:RFC 8259の値、オブジェクトの名前、数値の説明は仕様書を参照しました。ECMAScriptの内部アルゴリズムの全ステップとエンジン内部の実装を照合したわけではありません。実測した結果だけから、規格の歴史的な設計理由や内部処理の目的までは断定しません。
  • 変換フックの組合せ:本文では固定のreplacer、reviver、toJSONを使っています。getterやProxy、独自プロトタイプを含むオブジェクト、変換中に構造が変化する場合、フック自身が例外を投げる場合までは試していません。標準状態でのBigIntの例外やMapとSetの出力を、あらゆるカスタマイズ後の挙動に一般化しないでください。
  • 数値と日時の入力範囲:大整数の例は整数IDの丸めを示すものです。小数の丸め方式や他言語との相互運用全般は検証していません。日時も固定の有効な値で確認したため、無効な日付、タイムゾーンの境界、任意の形式の文字列の受理可否は別途検査が必要です。
  • 構文とデータの意味:不正なJSONの例を確認していますが、重複した名前を持つJSONの処理を複数のパーサーで比較してはいません。JSON.parseが成功した後の認可、業務ルール、入力サイズ制限を含むセキュリティ対策の網羅検証も対象外です。
  • ファイル操作と運用条件:同期APIとPromise APIの読み書き、およびコールバックAPIの存在を確認しました。コールバックAPIを含む全関数の動作、巨大ファイルでの速度やメモリ消費、同時書込み、書込み途中の障害、データの永続性は検証していません。非同期APIを選ぶだけで競合やデータ損失を防げるとは限りません。

本文の例をアプリケーションへ取り込む際は、入力データの契約と、処理に失敗した場合の扱いを先に決めてください。 ここで確認した変換結果を基準に、実際に受け付ける値と拒否する値をテストへ追加すると、シリアライズの前後で失いたくない情報を明示できます。

記事一覧へ戻る