列を追加したらGASが壊れた?ヘッダー名で列を特定する安全な書き方

Google Apps Script(GAS)でスプレッドシートを処理するとき、次のように列番号を直接指定していないでしょうか。
const projectName = rowValues[1]; // B列
const status = rowValues[2]; // C列シートの構成が変わらない間は、これでも正しく動きます。しかし、運用中に列が追加されたり並べ替えられたりすると、同じコードが別の列を読むようになります。
変更前:案件ID|案件名|ステータス|担当者|更新日時
変更後:案件ID|案件名|優先度 |ステータス|担当者|更新日時
↑ C列に新しい項目を追加変更前はC列だった「ステータス」が、変更後はD列へ移動しています。それでも rowValues[2] を読み続けると、「優先度」をステータスとして扱ってしまいます。
しかも、必ずエラーで止まるとは限りません。間違った列の値を正常に取得し、そのまま誤った更新を行う可能性があります。
そこで役立つのが、列番号ではなくヘッダー名から列位置を特定する「ヘッダーによる列解決」です。
この記事では、「案件ID」「ステータス」「更新日時」といったヘッダー名を使い、列追加や並べ替えに強いGASを書く方法を解説します。
getRange()・getValues()・二次元配列の基本は、前編:GASで列の値を取得するには?で解説しています。
この記事で解決できる課題
- 列を追加したらGASが別のデータを読むようになった
- 列の並べ替えだけで処理が壊れる
rowValues[4]が何の列なのか分かりにくい- 必須列が削除・改名されても処理が続いてしまう
- 同じ名前のヘッダーが複数あり、どちらを使ったか分からない
- ファイルごとに列順が異なる
固定した列番号はなぜ危険なのか
次の案件一覧を例に考えてみましょう。
| A列 | B列 | C列 | D列 | E列 |
|---|---|---|---|---|
| 案件ID | 案件名 | ステータス | 担当者 | 更新日時 |
ステータスが「完了」の行へ更新日時を設定する処理を、列番号で書くと次のようになります。
function updateCompletedAtByFixedColumn() {
const sheet = SpreadsheetApp
.getActiveSpreadsheet()
.getSheetByName('案件一覧');
const lastRow = sheet.getLastRow();
if (lastRow < 2) {
return;
}
const rowCount = lastRow - 1;
const values = sheet.getRange(2, 1, rowCount, 5).getValues();
const outputValues = values.map((rowValues) => {
const status = rowValues[2];
const currentUpdatedAt = rowValues[4];
return [status === '完了' ? new Date() : currentUpdatedAt];
});
sheet.getRange(2, 5, rowCount, 1).setValues(outputValues);
}このコードには、列の位置を表す数字が埋め込まれています。
rowValues[2]:C列のステータスrowValues[4]:E列の更新日時getRange(2, 5, ...):E列へ書き込む
こうした、コードだけでは意味を判断しにくい数字は「マジックナンバー」と呼ばれます。
列が増えるたびにコード内の数字を探して修正する運用では、修正漏れが起こりやすくなります。
基本:indexOf()でヘッダー名を探す
まず、1行目のヘッダーを一括取得し、「ステータス」と「更新日時」の位置を調べてみましょう。
function findHeaderIndexes() {
const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
const sheet = spreadsheet.getSheetByName('案件一覧');
const headerRow = 1;
const lastColumn = sheet.getLastColumn();
const headers = sheet
.getRange(headerRow, 1, 1, lastColumn)
.getDisplayValues()[0];
const statusIndex = headers.indexOf('ステータス');
const updatedAtIndex = headers.indexOf('更新日時');
console.log(JSON.stringify(headers));
console.log(`ステータスのインデックス:${statusIndex}`);
console.log(`更新日時のインデックス:${updatedAtIndex}`);
}Apps Scriptのエディタで findHeaderIndexes() を実行すると、ヘッダーの位置を実行ログで確認できます。
["案件ID","案件名","ステータス","担当者","更新日時"]
ステータスのインデックス:2
更新日時のインデックス:4getDisplayValues() の戻り値は二次元配列です。
[
['案件ID', '案件名', 'ステータス', '担当者', '更新日時'],
]今回は1行だけ取得しているため、末尾の [0] でヘッダー行を取り出しています。
関数内では、JavaScriptの indexOf() を使って目的のヘッダーを探しています。
ヘッダーが次の順番なら、取得結果は 2 と 4 です。
const headers = [
'案件ID',
'案件名',
'ステータス',
'担当者',
'更新日時',
];
headers.indexOf('ステータス'); // 2
headers.indexOf('更新日時'); // 4JavaScriptの配列は0から始まるため、C列のインデックスは 2、E列は 4 になります。
列の順番を変更すると、indexOf() の結果も現在の位置に合わせて変わります。
const headers = [
'案件ID',
'案件名',
'優先度',
'担当者',
'更新日時',
'ステータス',
];
headers.indexOf('ステータス'); // 5
headers.indexOf('更新日時'); // 4コード内の列番号を書き換えなくても、ヘッダー名から現在の列位置を取得できます。
まずは2列をヘッダー名で解決してみよう
先ほどの処理を、ヘッダー名から列を特定する形へ変更します。
function updateCompletedAt() {
const sheet = SpreadsheetApp
.getActiveSpreadsheet()
.getSheetByName('案件一覧');
const headerRow = 1;
const firstDataRow = headerRow + 1;
const lastRow = sheet.getLastRow();
const lastColumn = sheet.getLastColumn();
const headers = sheet
.getRange(headerRow, 1, 1, lastColumn)
.getDisplayValues()[0]
.map((header) => header.trim());
const statusIndex = headers.indexOf('ステータス');
const updatedAtIndex = headers.indexOf('更新日時');
if (statusIndex === -1) {
throw new Error('必須列「ステータス」が見つかりません。');
}
if (updatedAtIndex === -1) {
throw new Error('必須列「更新日時」が見つかりません。');
}
if (lastRow < firstDataRow) {
return;
}
const rowCount = lastRow - firstDataRow + 1;
const values = sheet
.getRange(firstDataRow, 1, rowCount, lastColumn)
.getValues();
const outputValues = values.map((rowValues) => {
const status = rowValues[statusIndex];
const currentUpdatedAt = rowValues[updatedAtIndex];
return [status === '完了' ? new Date() : currentUpdatedAt];
});
sheet
.getRange(firstDataRow, updatedAtIndex + 1, rowCount, 1)
.setValues(outputValues);
}列の順番が変わっても、「ステータス」と「更新日時」という名前から現在位置を取得できます。
また、書き戻しているのは更新日時の列だけです。読み込んだ行全体を setValues() で上書きしないため、数式列や別の担当者が編集した列まで巻き込むリスクを減らせます。
indexOf()が-1のまま処理しない
indexOf() は、目的の値が見つからない場合に -1 を返します。
const statusIndex = headers.indexOf('ステータス');ヘッダーが「状態」へ改名されていた場合、statusIndex は -1 です。
この値を確認せず配列へアクセスすると、期待した値を取得できません。
const status = rowValues[statusIndex]; // rowValues[-1]シートの列番号へ変換するために1を足すと 0 になり、getRange() の列番号としても不正です。
const statusColumn = statusIndex + 1; // 0目的のヘッダーが見つからなければ、分かりやすいエラーで早めに停止しましょう。
if (statusIndex === -1) {
throw new Error('必須列「ステータス」が見つかりません。');
}問題が起きたときに早い段階で処理を止める設計は「フェイルファスト」と呼ばれます。誤った列を更新してから問題に気づくより、安全に停止して原因を知らせる方が復旧しやすくなります。
ヘッダーによる列解決の注意点
ヘッダー名はシートとコードの契約になる
列順は自由に変更できますが、ヘッダー名まで自由に変えてよいわけではありません。
コードが「ステータス」という名前を探しているとき、利用者が「状態」へ変更すれば必須列エラーになります。ヘッダー名は、シートとコードをつなぐ識別子、つまり契約として扱いましょう。
運用ルールとして、次のような対策が有効です。
- ヘッダー行を保護する
- 必須ヘッダー名を仕様書やシート内の説明へ記載する
- 表示名を変更するときはコード側も同時に変更する
- 同じ意味のヘッダーを重複させない
前後の空白を取り除く
画面では同じに見えても、ステータス と ステータス は異なる文字列です。
const normalizedHeaders = headers.map(
(header) => String(header).trim(),
);前後の空白を trim() で除去してから比較すると、入力時の軽微な揺れに対応できます。
ただし、「ステータス」と「状態」のような別名まで自動で同一視するものではありません。別名を際限なく許可すると仕様が曖昧になるため、正式名称を1つに決めるのがおすすめです。
2段ヘッダーや結合セルに注意する
2段ヘッダーや結合セルを使った表では、1行目だけを読んでも一意な名前を取得できない場合があります。
GASで継続的に処理する表は、次のような構造にすると扱いやすくなります。
- ヘッダーは1行にする
- 1列につき一意なヘッダー名を付ける
- 結合セルを使わない
- データ途中に別の見出し行を入れない
見た目を整える表と、プログラムで扱いやすいデータ表は分けて考えることが大切です。
まとめ
スプレッドシートは、運用中に列が追加・並べ替えされやすいツールです。列番号だけに依存したGASは、表の小さな変更によって別の列を読んだり書いたりする可能性があります。
変更に強い処理を書くため、次の流れを基本にしましょう。
- ヘッダー行を一括取得する
- ヘッダー名から0始まりの列インデックスを作る
- 必須列の不足と同名ヘッダーを検証する
- 配列の読み取りにはインデックスをそのまま使う
getRange()の列番号にはインデックスへ1を足す- 書き戻しは必要な列だけに限定する
rowValues[2] のような数字だけのコードを見つけたら、「この列はヘッダー名から取得できないか」と考えてみてください。
列順ではなく列の意味で処理する。この設計へ変えるだけで、列追加や並べ替えに強く、意図を読み取りやすいGASになります。
参考
この記事をシェアする
合同会社raisexでは一緒に働く仲間を募集中です。
ご興味のある方は以下の採用情報をご確認ください。