3行まとめ
- CSVとJSONを相互変換するブラウザ完結ツールを作った。CSV→JSONはヘッダー有無で
[{列名:値}]かオブジェクトなしの[[...]]に出力形が変わる - CSV→JSON側はパースを papaparse に任せてRFC 4180の面倒を回避、JSON→CSV側は 先頭要素の形からオブジェクト配列か2次元配列かを推論してヘッダーを組み立てる、という非対称な作り
- 双方向変換には型が保存されないという原理的な限界がある。数値も日付もCSVを経由すると文字列になる。ここは正直に割り切る
REST APIのJSONレスポンスをExcelで見たい、逆にスプレッドシートのマスタをJSONで設定ファイルに埋め込みたい——CSVとJSONの往復は日常的に発生する。ワンライナーやスクリプトでも書けるが、RFC 4180のクォート処理やヘッダーの有無で毎回悩むのは面倒。
ぱんだツールズのCSV↔JSON変換は、CSVをJSONへ、JSONをCSVへ双方向に変換するツール。区切り文字(カンマ/タブ)やヘッダー行の有無を選べる。処理はブラウザ内で完結し、データはサーバーに送信されない。
この記事では、CSV→JSONでヘッダー有無により出力の「形」が変わる設計、JSON→CSVで入力JSONの構造を推論してCSVを組み立てるロジック、そして双方向変換につきまとう「型が保存されない」限界を、実装ベースで解説する。
CSV→JSON:ヘッダー有無で出力の形を変える
CSVをJSONにするとき、出力形は2通りありうる。1行目を列名として使えば [{ "名前": "田中", "年齢": "30" }] のオブジェクト配列に、使わなければ [["名前","年齢"], ["田中","30"]] の2次元配列になる。このツールはチェックボックスで切り替える。
function csvToJson(text: string, delimiter: Delimiter, hasHeader: boolean): string {
const parseResult = Papa.parse<string[]>(text, { delimiter, skipEmptyLines: true })
if (parseResult.errors.length > 0 && parseResult.data.length === 0) {
throw new Error('CSVの解析に失敗しました')
}
const rows = parseResult.data
if (rows.length === 0) return '[]'
if (hasHeader) {
const headers = rows[0]
const data = rows.slice(1).map((row) => {
const obj: Record<string, string> = {}
headers.forEach((h, i) => { obj[h] = row[i] ?? '' })
return obj
})
return JSON.stringify(data, null, 2)
}
return JSON.stringify(rows, null, 2)
}
CSVパースそのものは papaparse に丸投げする。RFC 4180の「ダブルクォートで囲まれたフィールド内のカンマは区切りとみなさない」「"" は1個の " にアンエスケープ」といった処理は自前で書くと地味に間違えるので、実績あるライブラリに任せるのが安全。delimiter を切り替えるだけでタブ区切り(TSV)にも対応できる。
ヘッダーありのとき、headers.forEach((h, i) => { obj[h] = row[i] ?? '' }) としているのがポイントで、データ行が短くて列が足りなくても ?? '' で空文字を補う。CSVは行によって列数がガタつくことがあるので、キー(ヘッダー)を基準に埋めて、欠けたセルは空文字で揃える。
JSON→CSV:先頭要素の「形」を見てヘッダーを決める
逆方向は自前で書く。JSONは配列であることだけ確認し、あとは先頭要素の型でデータの形を推論する。
function jsonToCsv(text: string, delimiter: Delimiter): string {
const parsed: unknown = JSON.parse(text)
if (!Array.isArray(parsed)) throw new Error('JSONの配列([...])を入力してください')
if (parsed.length === 0) return ''
const first = parsed[0]
if (typeof first === 'object' && first !== null && !Array.isArray(first)) {
// オブジェクトの配列 → 先頭のキーをヘッダーにする
const headers = Object.keys(first as Record<string, unknown>)
const rows = (parsed as Record<string, unknown>[]).map((row) =>
headers.map((h) => escapeCsvCell(String(row[h] ?? ''), delimiter)).join(delimiter)
)
return [headers.map((h) => escapeCsvCell(h, delimiter)).join(delimiter), ...rows].join('\n')
}
if (Array.isArray(first)) {
// 2次元配列 → そのまま行に
return (parsed as unknown[][]).map((row) =>
row.map((cell) => escapeCsvCell(String(cell), delimiter)).join(delimiter)
).join('\n')
}
throw new Error('対応していないJSON形式です。オブジェクトの配列か2次元配列を入力してください。')
}
分岐は3つ。先頭が「配列でないオブジェクト」ならオブジェクト配列とみなし、Object.keys(先頭) をヘッダーにする。先頭が配列なら2次元配列としてそのまま行にする。どちらでもなければ(数値の配列やプリミティブの配列など)エラーで弾く。CSV→JSONで作った2形式(オブジェクト配列・2次元配列)をちょうど逆変換で受け止められる対称性になっている。
ここに正直に書いておくべき限界がある。ヘッダーは「先頭要素のキー」だけから作る。もし2件目以降のオブジェクトに先頭にないキーがあっても、そのキーの列はCSVに出ない。逆に先頭にあって他にないキーは空文字になる。全要素のキーの和集合を取れば防げるが、その分「行ごとに列がある/ないを埋める」処理が要る。実務のJSON→CSVは「全レコードが同じキーを持つ」前提が多いので、まずは先頭基準のシンプルな実装にしている。不揃いなJSONを食わせる可能性があるなら、ここは和集合に拡張すべきポイント。
CSVセルのエスケープはRFC 4180
JSON→CSVの書き出しでは、セルにカンマ・ダブルクォート・改行が入るケースをRFC 4180に従ってクォートする。
function escapeCsvCell(value: string, delimiter: Delimiter): string {
if (value.includes('"') || value.includes(delimiter) || value.includes('\n')) {
return `"${value.replace(/"/g, '""')}"`
}
return value
}
ルールは「区切り文字・"・改行のいずれかを含むセルは全体をダブルクォートで囲み、内部の " は "" に二重化する」。区切り文字を引数で受けているので、カンマ区切りでもタブ区切りでも同じ関数で正しくクォートできる(タブモードなら「タブを含むセル」をクォート対象にする)。この二重化はCSV→JSON側で papaparse がアンエスケープしてくれる処理と表裏一体で、往復させても壊れないようになっている。
双方向変換の原理的な限界:型は保存されない
一番の落とし穴は、CSVを経由すると型情報が消えること。CSVはすべてのセルが文字列のフォーマットなので、JSON→CSV→JSONと往復すると、元が数値でも文字列になって戻ってくる。
これは実装にもそのまま出ている。JSON→CSVでは値を String(row[h] ?? '') と明示的に文字列化しているし、CSV→JSONでも各セルは papaparse が返す文字列のまま obj[h] に入る("30" であって 30 ではない)。だからCSV→JSONの出力は必ず値が全部文字列のJSONになる。
[
{ "名前": "田中太郎", "年齢": "30" }
]
"年齢": 30 を期待していると裏切られる。これを「数字っぽいセルは数値に変換する」と気を利かせると、電話番号やゼロ埋めの郵便番号("0120" → 120)が壊れるので、むやみに型推論しない方が安全という判断もある。このツールは全部文字列で通す割り切り。数値として使いたい場合は受け側で Number() するのが結局は事故が少ない。
同様に、値がネストしたオブジェクトや配列だった場合、String() は [object Object] のような文字列にしてしまう。CSVは表形式(フラットな行×列)のフォーマットなので、ネストした構造は素直には表現できない。CSV↔JSONは「フラットな表」の範囲でこそ往復が成立する、と割り切っておくのがいい。
まとめ
- CSV→JSONはヘッダー有無で出力形が変わる(
[{列名:値}]か[[...]])。パースは papaparse に任せてRFC 4180とTSVを吸収する - JSON→CSVは先頭要素の型でデータの形(オブジェクト配列/2次元配列)を推論してヘッダーを決める。ヘッダーを先頭要素のキーから作る実装は、不揃いなJSONでは列が欠けうる(防ぐなら全キーの和集合)
- CSVセルのエスケープはRFC 4180(区切り・
"・改行を含むセルをクォート、"は""に二重化)。papaparse のアンエスケープと表裏一体 - 双方向変換では型が保存されない。CSV経由で数値も文字列になる。むやみに型推論せず全部文字列で通すのが、郵便番号などを壊さない安全策
APIレスポンスの確認やマスタデータの相互変換にどうぞ。データはブラウザの外に出ない。
ぱんだツールズ では他にも CSV文字コード変換・銀行明細CSV変換・JSON整形・Excel変換など、データ変換系のブラウザ完結ツールを多数公開中。全部無料・登録不要・ファイルはサーバーに送られない。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。