スプレッドシートのシート追加ボタンが copyTo null で落ちる原因をデータだけで特定する
GASボタンの Cannot read properties of null (reading 'copyTo') は、シート名一覧と実シート名のズレ。Sheets API で実名・一覧列・プルダウンのデータ検証を突き合わせて壊れた参照を全件出し、退避先から復元して read-back 検証するまでの手順。末尾スペース事故とバインド型スクリプトに到達できない制約も含む。
約3.7万トークンの節約 (API料金換算で約56円分)。 要件定義・技術調査・試行錯誤ぶんのトークンがまるごと不要になります。※ 出品者申告とレビューに基づく推定値。モデル・タスク内容により変動します。
この巻物について
「スプレッドシートのシート追加ボタンが copyTo null で落ちる原因をデータだけで特定する」は、Google WorkspaceカテゴリのAI指示書(MDファイル)です。GASボタンの Cannot read properties of null (reading 'copyTo') は、シート名一覧と実シート名のズレ。Sheets API で実名・一覧列・プルダウンのデータ検証を突き合わせて壊れた参照を全件出し、退避先から復元して read-back 検証するまでの手順。末尾スペース事故とバインド型スクリプトに到達できない制約も含む。この巻物をAIに読み込ませると、ゼロから設計・調査する場合に比べて 約3.7万トークン(API料金換算で約56円)・93%のトークンを節約できます。
- カテゴリ
- Google Workspace
- 対応AI
- claude-code、cursor、codex-cli
- ライセンス
- 商用利用可 (再販不可)
- 価格
- 無料
- ゼロから開発時
- 約4万トークン
- この巻物使用時
- 約3,000トークン
- 節約量
- 約3.7万トークン (約56円)
- 更新日
- 2026-09-03
使い方 (AIに渡す3つの方法)
いちばん簡単なのはワンライナー。Claude Code のターミナルに貼るだけです。
claude "https://makimono-md.vercel.app/api/v1/files/copyto-null/raw を読み込んで、この指示書どおりに実装して"
中身
スプレッドシートの「シート追加ボタン」が Cannot read properties of null (reading 'copyTo') で落ちる原因を特定する
これは何の指示書か
Google スプレッドシート上の GAS ボタン(「シートを追加」「テンプレから一式コピー」等)が
TypeError: Cannot read properties of null (reading 'copyTo')
で落ちたときに、GAS のコードを読まずに、データだけで原因を確定させる手順。
このエラーはほぼ100%が
ss_src.getSheetByName(name).copyTo(ss_dest) // getSheetByName が null を返している
= 「コピー元スプレッドシートに、その名前のシートが無い」。コードのバグではなく **「シート名の一覧(プルダウン・目次・設定表)と、実際のシート名がズレた」**というデータ側の腐り。
長く運用されている業務スプレッドシートでは、次の理由で必ず起きる:
- コピー元からシートを削除・別ファイルへ退避したが、参照している一覧を直していない
- シートをリネームしたが、一覧が旧名のまま
- シート名の末尾に半角スペースが入っている(
アイテム一覧とアイテム一覧は別物) - プルダウンが
ONE_OF_LIST(値をベタ書き)で作られており、実データと連動していない
前提
- スプレッドシートを読める認証(サービスアカウント+ドメイン全体の委任、または OAuth)
- Node.js 18+(
fetch同梱)
手順
1. アクセストークンを用意する
サービスアカウント+ドメイン全体の委任で対象ユーザーになりきる。scope は
https://www.googleapis.com/auth/drive 1本で Sheets API も通る。
// drive-auth.mjs
import { createSign } from 'node:crypto';
import { readFileSync } from 'node:fs';
export async function getToken({ keyPath, impersonate, scope = 'https://www.googleapis.com/auth/drive' }) {
const key = JSON.parse(readFileSync(keyPath, 'utf8'));
const now = Math.floor(Date.now() / 1000);
const b64 = (v) => Buffer.from(v).toString('base64url');
const header = b64(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const claims = b64(JSON.stringify({
iss: key.client_email, sub: impersonate, scope,
aud: 'https://oauth2.googleapis.com/token', iat: now, exp: now + 3600,
}));
const signer = createSign('RSA-SHA256');
signer.update(`${header}.${claims}`);
const jwt = `${header}.${claims}.${signer.sign(key.private_key, 'base64url')}`;
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:jwt-bearer')}&assertion=${jwt}`,
});
const json = await res.json();
if (!json.access_token) throw new Error(`token error: ${JSON.stringify(json)}`);
return json.access_token;
}
2. 「実際のシート名」と「一覧に書かれた名前」を突き合わせる
必ず JSON.stringify で比較する。 末尾スペースは画面でもログでも目視判別できず、
これを見落とすと「名前は合っているのに null」で必ず迷子になる。
import { getToken } from './drive-auth.mjs';
const SRC = '<コピー元スプレッドシートID>'; // シートを持っている側
const DEST = '<ボタンがある側のスプレッドシートID>';
const LIST_SHEET = '<一覧が書かれているシート名>';
// 一覧が入っている列(0=A)。複数列あるなら全部入れる
const LIST_COLS = [0, 5, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23];
const HEADER_ROWS = 4; // 見出し行数。これ以下の行は無視する
const token = await getToken({ keyPath: '<SAキーのパス>', impersonate: '<なりきる相手のメール>' });
const api = async (url) => {
const r = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
const t = await r.text();
if (!r.ok) throw new Error(`${r.status} ${t.slice(0, 300)}`);
return JSON.parse(t);
};
// 実際のシート名
const meta = await api(`https://sheets.googleapis.com/v4/spreadsheets/${SRC}?fields=sheets.properties(sheetId,title)`);
const real = new Set(meta.sheets.map((s) => s.properties.title));
console.log(`コピー元: ${real.size} シート`);
// 一覧に書かれた名前
const col = (i) => { let s = '', n = i; do { s = String.fromCharCode(65 + (n % 26)) + s; n = Math.floor(n / 26) - 1; } while (n >= 0); return s; };
const grid = await api(
`https://sheets.googleapis.com/v4/spreadsheets/${DEST}/values/${encodeURIComponent(`${LIST_SHEET}!A1:Z200`)}`
);
const broken = [];
(grid.values || []).forEach((row, ri) => row.forEach((cell, ci) => {
if (typeof cell !== 'string' || !cell.trim()) return;
if (ri < HEADER_ROWS || !LIST_COLS.includes(ci)) return;
if (!real.has(cell)) broken.push(`${col(ci)}${ri + 1}\t${JSON.stringify(cell)}`);
}));
console.log(`\n実在しない参照 ${broken.length} 件`);
console.log(broken.join('\n'));
3. プルダウンの中身も別に検査する(ここが最大の落とし穴)
セルの値ではなく**データ検証(データの入力規則)**を見る。長期運用のシートでは
ONE_OF_LIST(値のベタ書き)で作られていることが多く、実データと一切連動していない。
一覧列が最新でも、プルダウンだけが数年前の名前を出し続ける。
const dv = await api(
`https://sheets.googleapis.com/v4/spreadsheets/${DEST}` +
`?ranges=${encodeURIComponent(`${LIST_SHEET}!A1:H10`)}&includeGridData=true` +
`&fields=sheets(data(rowData(values(userEnteredValue,dataValidation))))`
);
const rows = dv.sheets?.[0]?.data?.[0]?.rowData || [];
rows.forEach((row, ri) => (row.values || []).forEach((cell, ci) => {
const cond = cell.dataValidation?.condition;
if (!cond) return;
if (cond.type === 'ONE_OF_LIST') {
const list = cond.values.map((v) => v.userEnteredValue);
const dead = list.filter((n) => !real.has(n));
console.log(`${col(ci)}${ri + 1} ONE_OF_LIST ${list.length}件 / 実在しない ${dead.length}件`);
console.log(' ', dead.map((n) => JSON.stringify(n)).join(', '));
} else {
console.log(`${col(ci)}${ri + 1} ${cond.type}`, JSON.stringify(cond.values));
}
}));
4. 「消えたシート」が退避されていないか探す
削除ではなく別ファイルへの退避であることが多い(「倉庫」「アーカイブ」「旧」等の名前)。 過去のバックアップコピーと現在を比べると、いつ消えたかも切り分けられる。
// 候補ファイルを名前で探す
const q = encodeURIComponent(`title contains '<一覧側の名前の一部>' and mimeType = 'application/vnd.google-apps.spreadsheet'`);
const found = await api(`https://www.googleapis.com/drive/v3/files?q=${q}&fields=files(id,name,modifiedTime)`);
// いつ消えたかの切り分け: 古いコピーに目的のシートが在るか
for (const f of found.files) {
const m = await api(`https://sheets.googleapis.com/v4/spreadsheets/${f.id}?fields=sheets.properties.title`);
const names = m.sheets.map((s) => s.properties.title);
console.log(f.name, names.includes('<消えたシート名>') ? '有り' : '無し');
}
// 変更履歴で「誰がいつ触ったか」
const rev = await api(
`https://www.googleapis.com/drive/v3/files/${SRC}/revisions` +
`?fields=revisions(id,modifiedTime,lastModifyingUser/emailAddress)&pageSize=100`
);
(rev.revisions || []).slice(-15).forEach((r) => console.log(r.modifiedTime, r.lastModifyingUser?.emailAddress));
5. 直す
優先順に:
-
消えたシートを退避先から戻す — 追加のみ=削除なしで、取り消しは「追加した1枚を消す」だけ。 一覧の複数箇所が同じ名前を参照している場合、1操作で全部直り、既に配布済みのコピーにも効く。 API なら「シートを別ファイルへコピー → リネーム」の2手。
const copied = await fetch( `https://sheets.googleapis.com/v4/spreadsheets/${ARCHIVE}/sheets/${SRC_SHEET_ID}:copyTo`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ destinationSpreadsheetId: SRC }) } ).then((r) => r.json()); await fetch(`https://sheets.googleapis.com/v4/spreadsheets/${SRC}:batchUpdate`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ requests: [{ updateSheetProperties: { properties: { sheetId: copied.sheetId, title: '<戻したい名前>' }, fields: 'title', } }] }), }); -
プルダウンを実データ参照に変える —
ONE_OF_LISTを捨て、実シート名を書き出している列をONE_OF_RANGEで参照させる。これで「存在しない名前を選べる」状態が構造的に消える。body: JSON.stringify({ requests: [{ setDataValidation: { range: { sheetId: <一覧シートのsheetId>, startRowIndex: 3, endRowIndex: 4, startColumnIndex: 1, endColumnIndex: 2 }, rule: { condition: { type: 'ONE_OF_RANGE', values: [{ userEnteredValue: `='${LIST_SHEET}'!$A$5:$A$500` }] }, showCustomUi: true }, } }] }) -
コード側に null ガードを入れる(できる場合)— 落ちる代わりに何が無いかを言わせる。 前後スペースを無視した照合も入れておくと、末尾スペース事故を吸収できる。
function findSheet(ss, name) { return ss.getSheetByName(name) || ss.getSheets().find((s) => s.getName().trim() === String(name).trim()) || null; } const src = findSheet(ss_src, name); if (!src) throw new Error(`コピー元にシート「${name}」がありません。シート名一覧を更新してください。`); src.copyTo(ss_dest);
6. 直ったことを実データで確認する(read-back verify)
「直したはず」で終わらせない。手順2をもう一度回して未解決件数が減ったことと、 ボタンが読むセルの現在値が実在名に解決することの2点を数字で出す。
console.log(`未解決参照 ${broken.length} 件(修正前 N 件)`);
const cur = (await api(`https://sheets.googleapis.com/v4/spreadsheets/${DEST}/values/${encodeURIComponent(`${LIST_SHEET}!B4`)}`)).values?.[0]?.[0];
console.log(`選択値 ${JSON.stringify(cur)} -> ${real.has(cur) ? 'OK' : 'まだ落ちる'}`);
詰まりやすい点
- コンテナバインド型 GAS のコードには外から到達できない。 スプレッドシートに紐づいた スクリプトの ID を列挙する API は存在せず、Drive の子要素クエリでも 0 件が返る (バインド型スクリプトは Drive のファイルとして現れない)。コードを直すなら 「拡張機能 → Apps Script」で開いた URL を人から受け取るしかない。データ側の修正だけなら不要。
- 一式コピーが
try { } catch { }で囲まれていると、存在しないシートは黙って抜け落ちる。 「エラーが出ない」=「正常」ではない。手順2の突き合わせでしか気づけない。 - 一覧列の開始行はファイルごとにズレる。 テンプレと配布済みコピーで見出し行数が違うことがあるので、
HEADER_ROWSを決め打ちする前に実際の列を1回全部出して目で確認する。 - 旧形式の URL からは ID が読めない。
spreadsheet/ccc?key=0A...形式が設定セルに残っていると 参照先が分からないので、突き合わせの前に「実際に開いて ID を控える」か、 ファイル名で Drive を検索して実体を特定する。 - 本番の共有マスタへの書き込みは承認を取ってから。 一覧を書き換える修正は そのファイルをコピーして使っている全ての配布先に波及する。差分の件数を先に出して合意を取る。
よくある質問
+「スプレッドシートのシート追加ボタンが copyTo null で落ちる原因をデータだけで特定する」とは何ですか?
GASボタンの Cannot read properties of null (reading 'copyTo') は、シート名一覧と実シート名のズレ。Sheets API で実名・一覧列・プルダウンのデータ検証を突き合わせて壊れた参照を全件出し、退避先から復元して read-back 検証するまでの手順。末尾スペース事故とバインド型スクリプトに到達できない制約も含む。
+どれくらいトークン(費用)を節約できますか?
ゼロから開発すると約4万トークンかかりますが、この巻物を使えば約3,000トークンで済みます。差し引き約3.7万トークン(API料金換算で約56円)・93%の節約です。
+どうやって使いますか?
無料です。MDファイルを Claude Code などのAIに読み込ませるだけ。ワンライナーをターミナルに貼れば実装が始まります。要件定義や技術調査を省いて実装だけにトークンを使えます。
+どのAIツールに対応していますか?
claude-code、cursor、codex-cli に対応しています。
+商用利用できますか?
ライセンスは「商用利用可 (再販不可)」です。
🤝 自分でAIを動かすのは、まだ不安…という方へ
この巻物の内容を、AIを使うプロに丸ごと任せることもできます。姉妹サービスAI代行堂なら「LINEで頼むだけで、仕事が完成」。
関連する巻物
GAS完全自動化テンプレ — Driveコマンドキュー方式
Google Apps Script の「毎回エディタで▶実行」を根絶。Drive 経由のコマンドキューで、初回1クリック以降は AI がすべての GAS 関数をリモート実行できるようになるテンプレート指示書。
人間の手入力台帳を壊さずに自動更新する — GAS Web App upsert 設計
各PC/各拠点の点検結果を、人間が手運用しているスプレッドシート台帳へ自動反映する。手入力列とコメントを絶対に壊さない突合設計、タブ/列の解決、並行POST対策、配布シークレットの落とし穴まで。
数式まみれの業務スプレッドシートを、Webアプリから壊さずに編集させる型
ArrayFormula と per-row 数式が混在する台帳を、セル単位 allowlist・dry-run 既定・適用前バックアップ・触っていないセルの数式不変検査で安全に書き換える設計手順。列ごとの数式復元と、テストが緑のまま壊れる典型例つき。
この巻物、誰かのトークンも救えます
𝕏 で節約レシートをシェア