0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CSV↔JSON双方向変換をブラウザで実装する — ヘッダー有無で変わる出力形と、JSONの形からCSVを組み立てる

0
Posted at

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 にも同じ内容を投稿しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?