← 記事一覧

JSON 文字列リテラルの不正な制御文字: 修正方法

JSON 文字列の中の生のタブ、改行、null バイト、ANSI エスケープがこのエラーを引き起こす。JSON 仕様が禁じる理由、混入する経路、剥がし方/エスケープ方法を学ぶ。

SyntaxError: Bad control character in string literal in JSON at position N は、JSON 文字列の中にエスケープされていない生の制御文字 —— タブ、改行、復帰、または U+0000–U+001F 範囲の任意の文字 —— が存在することを意味します。JSON 仕様はそれを禁じます。本記事ではなぜそうなるか、どこから来るか、どう取り除くかを解説します。

どの文字列エラーが出ている?

  • Bad control character —— 生のタブ/改行/ヌルバイトが文字列の内部 に未エスケープで存在。
  • Bad escaped character —— \ の後に JSON が認めないもの(例: \x、Windows パス)が続いている。
  • Unterminated string —— 文字列を " で開いたまま閉じていない。

制御文字とは?

Unicode の最初の 32 コードポイント(U+0000〜U+001F)は制御文字 —— テレタイプ時代から引き継がれた不可視のフォーマット制御コード。お馴染みのものを少し:

コードポイント名称JSON エスケープ
U+0000Null
U+0008Backspace\b
U+0009水平タブ\t
U+000ALine feed(改行)\n
U+000CForm feed\f
U+000DCarriage return\r
U+001BEscape(ANSI シーケンスの開始)

JSON 仕様(RFC 8259 §7)は明示的:この範囲のすべての文字は、JSON 文字列内に現れる場合 必ずエスケープしなければならない。引用符で囲まれた文字列内の生の改行は構文エラーであって、値ではない。

なぜ生の改行が JSON を壊すか

文字列値の中に本物の改行文字が入っているケースを考えましょう:

// バイト列の見た目(改行はリテラル、\n ではない):
{"message":"line one
line two"}

JSON.parse('{"message":"line one\nline two"}')
// SyntaxError: Bad control character in string literal in JSON at position 20

パーサは開きの二重引用符を見て、文字を読み進め、生の 0x0A バイトに当たります。JSON 文字列は同じ論理行上の二つの二重引用符の間で途切れない一連の文字列でなければならないので、裸の改行が文字列を予期せず終了させてしまいます。

正しい表現は二文字のエスケープシーケンス \n を使うこと:

// 正しい —— \n はエスケープであり、リテラルな改行ではない
{"message":"line one\nline two"}

JSON.parse('{"message":"line one\nline two"}') // ✓ 動作

制御文字が JSON に混入する経路

1. テンプレートリテラルや連結で JSON 文字列を組み立てる

const note = `first line
second line`;                   // テンプレートリテラル内の本物の改行

const json = `{"note":"${note}"}`; // 生の改行が文字列に埋め込まれる
JSON.parse(json);               // SyntaxError: Bad control character…

// ✓ 修正:値には常に JSON.stringify を使い、手動で補間しない
const json = JSON.stringify({ note });  // 改行を自動的にエスケープ

2. ファイルを読みコンテンツをそのまま埋め込む

import fs from 'fs';
const content = fs.readFileSync('notes.txt', 'utf8');
// content には \n、\r\n、\t などが含まれうる

// ❌ 手動で文字列を構築 —— 生の制御文字が埋め込まれる
const json = '{"content":"' + content + '"}';

// ✓ JSON.stringify がすべてのエスケープを処理
const json = JSON.stringify({ content });

3. 出力をエスケープしないシステムからの API レスポンス

一部のバックエンドシステム(レガシー PHP スクリプト、独自シリアライザ、JSON を手で組み立てる DB トリガ)はフィールド値を未エスケープの改行やタブ付きで出力します。受け取るのは構文的に不正な JSON で、response.json() が投げます。

原因の特定には、生のボディの長さと先頭 200 文字をログ。文字列値であるべきところに可視の改行がないか確認。

4. ターミナルからのコピペ(ANSI エスケープシーケンス)

ターミナル出力には ANSI 色コードが含まれ、Escape 文字(0x1B)に続いて [ で始まります。ターミナル出力を JSON 文字列に貼り付けると、色リセット(\x1B[0m)ごとに制御文字になります。

// JSON 文字列にターミナル出力を貼り付けた例:
{"log":"\u001B[32mOK\u001B[0m request processed"}
//      ^^^ 生の ESC バイト —— 本来は 6 文字の \u001B エスケープシーケンス

5. バイナリデータ由来のヌルバイト

バイナリファイルの一部、DB の BLOB カラム、C スタイル文字列を JSON フィールドに読み込むと、ヌルバイト(U+0000)が埋め込まれる可能性 —— DB から JSON へのパイプラインでこのエラーの最も一般的な源。

直し方

予防:常に JSON.stringify を使う

これが決定打。JSON.stringify() はすべての制御文字を正しくエスケープ —— 手動でやる必要は一切ない。

// ✓ userInput に何が入っていても安全
const json = JSON.stringify({ message: userInput });

修復:事後にストリップまたはエスケープ

外部ソースから壊れた JSON を受け取り、プロデューサ側を直せない場合、解析前に生文字列をサニタイズできます。最も安全なのは裸の制御文字をエスケープすること:

function escapeControlChars(raw) {
  return raw.replace(
    /[\u0000-\u001F]/g,
    (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0')
  );
}

const fixed = escapeControlChars(rawFromApi);
const data  = JSON.parse(fixed);

制御文字が意味のあるデータではなくノイズ(ANSI コード、ヌルバイト)であれば、削除する方がシンプル:

// \t、\n、\r(テキストでよく出る)以外の制御文字をすべて削除
const cleaned = raw.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '');
const data = JSON.parse(cleaned);

現実世界の二つの追加要因

  • position 0 の UTF-8 BOM。 「UTF-8 with BOM」で保存されたファイルは 0xEF 0xBB 0xBF で始まる。RFC 8259 は JSON での BOM を禁じ、 JSON.parse は position 0 エラーで拒否しますが、これは制御文字エラーと誤読されやすい。解析前に text.replace(/^/, '') で除去するか、エディタで「UTF-8」(BOM なし)として保存する。
  • VS Code: 「Remove Control Characters」。 一つのファイルを手で整理するなら、VS Code の Selection → Remove All Occurrences of Find Match と正規表現検索 [\x00-\x08\x0b\x0c\x0e-\x1f](本物の \t\n\r は保持)を組み合わせると、不可視の制御バイトを一括削除できる。よくやるなら拡張機能の 「Remove Control Characters」コマンドもある。

問題位置の検出

エラーメッセージにバイト位置が含まれる。それで何があるか調べる:

try {
  JSON.parse(raw);
} catch (e) {
  const pos = Number(e.message.match(/position (\d+)/)?.[1]);
  if (!isNaN(pos)) {
    const context = raw.slice(Math.max(0, pos - 20), pos + 20);
    console.log('Context around error:', JSON.stringify(context));
    // JSON.stringify が制御文字を再エスケープしてくれるので見える
    // 例: "...line one\nline two..." —— その \n は元々生の改行
  }
}

よくある質問

JSON の「bad control character」とは?

JSON 文字列内に置かれた、U+0000〜U+001F 範囲の未エスケープ生文字 —— タブ、改行、復帰、ヌルバイト、または ANSI escape。RFC 8259 §7 は、こうした文字すべてのエスケープを要求(例:改行は \n)。

bad control character エラーをどう直す?

決定打は JSON を JSON.stringify() で構築すること。制御文字を自動的にエスケープする。ソースで直せない壊れた JSON を受け取る場合は、解析前に正規表現でエスケープまたは削除(上のスニペット参照)。

なぜ生の改行は JSON を壊す?

JSON 文字列は二つの二重引用符の間の途切れない一連の文字でなければなりません。リテラルな改行バイト(0x0A)は文字列を早期に終了させ、パーサは制御文字エラーを報告。妥当な表現は二文字のエスケープ \n

JSON から ANSI 色コードを取り除くには?

ANSI シーケンスは Escape バイト(U+001B)から始まる。解析前に raw.replace(/\[[0-9;]*m/g, '') で削除するか、そもそも生のターミナル出力を JSON 文字列値に貼らない。

ブラウザで壊れた JSON を修復

壊れた JSON を fixjson.org の JSON Fix に貼る。寛容なパーサが制御文字違反を特定し、正確な位置を報告。単純なケースなら自動で文字列を修復できる。