はじめに
前回の記事では、どんな関数の結果にも対応できる万能カスタム関数UNIVERSAL_WRAPと、数式を自動でスキャンして包み込む仕組みを実装しました。ただし検証は手元のシート上で数式を1つずつ試したものにとどまり、「Excelファイルをインポートした直後に自動適用する」という本来の目的地にはまだ届いていません。
今回は、あらかじめGASコードを仕込んだ「親ファイル」に、Excelファイルを「新しいシートとして」インポートするという運用フローで実機検証します。この前提を守ることで、カスタム関数はスクリプトが紐づいたスプレッドシート上でしか呼び出せないという制約を、そもそも発生させずに済みます。
実際に検証を進めると、Excelの動的配列関数をGoogleスプレッドシートにインポートしたときの非互換性は4パターンに分かれることが分かりました。今回はその全貌を解説します。
全体像:親ファイル方式の運用フロー
今回検証する運用は、「事前準備(1回だけ)」と「毎回の運用(3ステップ)」に分かれます。
【事前準備:1回だけ】
①GASコード(onOpen・自動ラップ関数・UNIVERSAL_WRAP)を仕込んだ「親ファイル」を1つ用意する
【毎回の運用:3ステップ】
②Excelファイルを、親ファイルに「ファイル→インポート→新しいシートを挿入する」で取り込む
③親ファイルのメニューから「自動ラップ実行」をクリックする
④結果を確認し、必要に応じて「コピーを作成」で成果物として保存する
なぜ「新しいシートとして」親ファイルにインポートするのか: Excelファイルを完全に新しい別ファイルとしてインポート・変換してしまうと、その新しいファイルにはGASコードが一切紐づいていないため、カスタム関数もメニューも使えません。あらかじめコードを仕込んだ親ファイルの中にシートとして取り込むことで、この問題を根本から回避します(実機確認済み)。
準備:親ファイルを作成する
- Googleドライブで新しいスプレッドシートを作成し、分かりやすい名前(例:「スピル自動ラップ用フォーマット」)に変更する
- メニュー「拡張機能」→「Apps Script」を開き、次のStep1のコードを貼り付けて保存する
- スプレッドシートの画面をブラウザでリロードする
- メニューバーに独自メニューが表示されることを確認する
Step1:自動ラップの仕組みを実装する(最終版)
検証の過程で、単純な実装では対応しきれない落とし穴が2つ見つかりました(詳細はStep4で解説します)。それらを踏まえた最終版のコードを先に掲載します。(③のARRAY_CONSTRAIN対応は、公開後に#スピル!だったケースでのデータ消失が判明し、2026-09-22に追記した修正です)。
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('スピル自動ラップ')
.addItem('数式にUNIVERSAL_WRAPを自動設定', 'applyUniversalWrapToFormulas')
.addToUi();
}
/**
* 全シートの数式をスキャンし、対象関数を含む数式だけをUNIVERSAL_WRAPで自動的に包む。
* 範囲一括setFormulas()は数式以外の生データを消す危険があるため、
* 該当セルだけをsetFormula()で個別に書き換えている(前回の記事参照)。
* targetFunctionsにEXPAND・MAXIFSがないのは、自動ラップでは解決できない真の限界のため(Step5参照)。
*/
function applyUniversalWrapToFormulas() {
const targetFunctions = ['XLOOKUP', 'FILTER', 'UNIQUE', 'SORT', 'SEQUENCE'];
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheets = ss.getSheets();
let updatedCount = 0;
sheets.forEach(function(sheet) {
const lastRow = sheet.getLastRow();
const lastCol = sheet.getLastColumn();
if (lastRow === 0 || lastCol === 0) return;
const formulas = sheet.getRange(1, 1, lastRow, lastCol).getFormulas();
for (let r = 0; r < formulas.length; r++) {
for (let c = 0; c < formulas[r].length; c++) {
let formula = formulas[r][c];
if (!formula || formula.indexOf('UNIVERSAL_WRAP') !== -1) continue;
// ①Excel由来の名前空間プレフィックス(_xlws. / _xlfn.)を除去
let cleaned = formula.replace(/_xlws\.|_xlfn\./g, '');
// ②FILTER(...)のif_empty引数(第3引数の文字列リテラル)を、
// ネイティブのIFERRORに置き換える(UNIVERSAL_WRAPにエラーを渡さないため)
cleaned = rewriteFilterIfEmptyToIferror(cleaned);
const hasTarget = targetFunctions.some(function(fn) {
return new RegExp(fn + '\\s*\\(', 'i').test(cleaned);
});
if (!hasTarget) continue;
// ③ARRAY_CONSTRAINの行数・列数は常に信用せず、常に取り除く。
// 元のExcelファイルでスピルがブロック(#スピル!)されていた場合、この数値が
// ブロックされた時点の小さい暫定値に書き換えられていることがあり、しかも
// エラーにはならず一見成功したように見えるため、エラーの有無では判定できない。
// クリアする範囲だけは申告値をそのまま使う(無関係なデータを誤って消さないため)。
const footprint = getSpillFootprint(cleaned) || { rows: 1, cols: 1 };
const finalFormula = stripArrayConstrain(cleaned);
sheet.getRange(r + 1, c + 1, footprint.rows, footprint.cols).clearContent();
const newFormula = 'UNIVERSAL_WRAP(' + finalFormula.substring(1) + ')';
sheet.getRange(r + 1, c + 1).setFormula('=' + newFormula);
updatedCount++;
}
}
});
ss.toast(updatedCount + '件の数式を自動ラップしました', '完了', 3);
}
/**
* FILTER(...)の第3引数が文字列リテラルなら、FILTER呼び出し自体を
* IFERROR(FILTER(残りの引数), 元の文字列) に書き換える(詳細はStep4)。
*/
function rewriteFilterIfEmptyToIferror(formula) {
const upper = formula.toUpperCase();
const idx = upper.indexOf('FILTER(');
if (idx === -1) return formula;
const openIdx = idx + 'FILTER'.length;
const closeIdx = findMatchingParen(formula, openIdx);
if (closeIdx === -1) return formula;
const argsText = formula.substring(openIdx + 1, closeIdx);
const args = splitTopLevelArgs(argsText);
if (args.length >= 3 && /^"[^"]*"$/.test(args[args.length - 1].trim())) {
const ifEmptyText = args[args.length - 1].trim();
const newArgs = args.slice(0, args.length - 1);
const newFilterCall = 'IFERROR(FILTER(' + newArgs.join(',') + '),' + ifEmptyText + ')';
const rebuilt = formula.substring(0, idx) + newFilterCall + formula.substring(closeIdx + 1);
return rewriteFilterIfEmptyToIferror(rebuilt); // 他にもFILTERがあれば再帰的に処理
}
return formula;
}
/**
* openIdxの"("に対応する")"を、引用符内を除いて括弧の深さで探す(Step4参照)。
*/
function findMatchingParen(str, openIdx) {
let depth = 0;
let inQuote = false;
for (let i = openIdx; i < str.length; i++) {
const ch = str[i];
if (ch === '"') {
inQuote = !inQuote;
} else if (!inQuote) {
if (ch === '(') depth++;
else if (ch === ')') {
depth--;
if (depth === 0) return i;
}
}
}
return -1;
}
/**
* 括弧・引用符を考慮しながら、トップレベルのカンマで引数を分割する。
*/
function splitTopLevelArgs(argsText) {
const args = [];
let depth = 0;
let inQuote = false;
let current = '';
for (let i = 0; i < argsText.length; i++) {
const ch = argsText[i];
if (ch === '"') {
inQuote = !inQuote;
current += ch;
} else if (!inQuote && ch === '(') {
depth++;
current += ch;
} else if (!inQuote && ch === ')') {
depth--;
current += ch;
} else if (!inQuote && ch === ',' && depth === 0) {
args.push(current);
current = '';
} else {
current += ch;
}
}
if (current.length > 0) args.push(current);
return args;
}
/**
* ARRAY_CONSTRAIN(...,行数,列数)の最後の2引数から、スピルする行数・列数を取り出す。
* ARRAY_CONSTRAINで囲まれていない数式の場合はnullを返す(呼び出し側で1x1として扱う)。
*/
function getSpillFootprint(formula) {
const upper = formula.toUpperCase();
const idx = upper.indexOf('ARRAY_CONSTRAIN(');
if (idx === -1) return null;
const openIdx = idx + 'ARRAY_CONSTRAIN'.length;
const closeIdx = findMatchingParen(formula, openIdx);
if (closeIdx === -1) return null;
const argsText = formula.substring(openIdx + 1, closeIdx);
const args = splitTopLevelArgs(argsText);
if (args.length < 3) return null;
const rows = parseInt(args[args.length - 2].trim(), 10);
const cols = parseInt(args[args.length - 1].trim(), 10);
if (isNaN(rows) || isNaN(cols)) return null;
return { rows: rows, cols: cols };
}
/**
* ARRAY_CONSTRAIN(元の式,行数,列数)から、ARRAY_CONSTRAINだけを取り除いて元の式を返す。
* 元のExcelファイルの時点でスピルがブロックされていた数式は、ARRAY_CONSTRAINの行数・列数が
* ブロックされていた時点の暫定値である可能性があるため、信用せずに取り除く。
*/
function stripArrayConstrain(formula) {
const upper = formula.toUpperCase();
const idx = upper.indexOf('ARRAY_CONSTRAIN(');
if (idx === -1) return formula;
const openIdx = idx + 'ARRAY_CONSTRAIN'.length;
const closeIdx = findMatchingParen(formula, openIdx);
if (closeIdx === -1) return formula;
const argsText = formula.substring(openIdx + 1, closeIdx);
const args = splitTopLevelArgs(argsText);
if (args.length < 1) return formula;
const inner = args[0].trim();
return formula.substring(0, idx) + inner + formula.substring(closeIdx + 1);
}
/**
* 単一の値・2次元配列(スピル結果)のどちらが渡されても、同じロジックで一括処理して返す万能カスタム関数。
* @customfunction
*/
function UNIVERSAL_WRAP(inputData) {
if (!Array.isArray(inputData)) {
return cleanValue(inputData);
}
return inputData.map(function(row) {
return row.map(cleanValue);
});
}
function cleanValue(value) {
if (typeof value === 'string') {
return value.replace(/^[\s ]+|[\s ]+$/g, '');
}
return value;
}全シートをループする理由: 「新しいシートだけに絞る」案も検討しましたが、実機で全シートループ版を試しても他のシートを巻き込んだり生データを消したりする問題は起きなかったため(UNIVERSAL_WRAP済みの数式は最初にスキップするので二重ラップの心配もありません)、シンプルさを優先してこのままにしています。書き戻しは引き続き範囲一括ではなく個別setFormula()です。
Step2:Excelファイルを新しいシートとしてインポートする
- 親ファイルを開いた状態で、メニュー「ファイル」→「インポート」をクリック
- 「アップロード」タブから、スピル関数を含むExcelファイル(.xlsx)を選択
- インポート場所の選択肢で、必ず「新しいシートを挿入する」を選ぶ
- 「データをインポート」をクリック
注意: ここで「スプレッドシートを置換する」を選ぶと、警告どおり親ファイルの中身がStep1で仕込んだGASのコードごとすべて消えることを実機確認済みです。必ず「新しいシートを挿入する」を選んでください。
Step3:メニューから自動ラップを実行する
- インポートされた新しいシートをアクティブにする
- メニュー「スピル自動ラップ」→「数式にUNIVERSAL_WRAPを自動設定」をクリック
- 初回実行時は「承認が必要です」という認証ダイアログが表示されるため、画面の指示に従って許可する
- 完了すると「◯件の数式を自動ラップしました」というトースト通知が表示される
この基本フローが正しく動くことは実機で確認できましたが、実際にはここから先が本題でした。次のStepで、FILTER関数を例に何が起きたのかを詳しく解説します。
応用:メニュークリックを自動化する(2026-09-22追記)
Step3の「メニューから自動ラップを実行する」という手順を省略したい場合、onChangeトリガー(スプレッドシートの構成が変わるたびに自動実行されるインストール型トリガー)で自動化できます。
注意:トリガーの作成はonOpen()の中からは行えません。 onOpen()は自動実行時「簡易トリガー」として動くため、ScriptApp.newTrigger().create()のような認可が必要な操作の権限がありません(エラーにもならず静かに失敗します)。トリガー作成専用のメニュー項目を用意し、ユーザーが手動で1回クリックする必要があります。
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('スピル自動ラップ')
.addItem('数式にUNIVERSAL_WRAPを自動設定', 'applyUniversalWrapToFormulas')
.addItem('自動実行を有効にする(初回のみ)', 'ensureOnChangeTrigger')
.addToUi();
}
/**
* onChangeのインストール型トリガーを一度だけ作成する(メニューから手動実行すること)。
*/
function ensureOnChangeTrigger() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const alreadyExists = ScriptApp.getProjectTriggers().some(function(t) {
return t.getHandlerFunction() === 'onSpreadsheetChange' && t.getEventType() === ScriptApp.EventType.ON_CHANGE;
});
if (!alreadyExists) {
ScriptApp.newTrigger('onSpreadsheetChange')
.forSpreadsheet(ss)
.onChange()
.create();
}
}
/**
* スプレッドシートの構成が変わるたびに呼ばれる(詳細は下記の解説参照)。
*/
function onSpreadsheetChange(e) {
if (e.changeType === 'INSERT_GRID' || e.changeType === 'OTHER') {
applyUniversalWrapToFormulas();
}
}メニューの「自動実行を有効にする(初回のみ)」を手動で1回クリックすればトリガーが作成され、以後は自動で実行されます。
実機で判明: Excelインポート操作のchangeTypeは、実行のたびに'INSERT_GRID'になったり'OTHER'になったりと安定しませんでした(このシリーズでたびたび遭遇してきた「同じ操作でも挙動が変わる」というGAS特有の再現性のなさと同種の現象)。そのため両方を対象にしています。
Step4:FILTER関数を例に見る、2段階の非互換性
日本百名山のデータ(山名・標高・都道府県など)を使い、標高で絞り込むFILTER数式を含むExcelファイルをインポートして検証しました。

発見①:_xlws.という名前空間プレフィックスが残る
インポートされた数式を確認すると、次のような形になっていました。
=ARRAY_CONSTRAIN(ARRAYFORMULA(_xlws.FILTER(A2:E101,(D2:D101<J1)+(D2:D101>=J2),"該当なし")), 6, 5)
FILTERではなく_xlws.FILTERという名前空間付きの関数名になっており、Googleスプレッドシートが「不明な関数」として#NAME?エラーを返していました。動的配列関数はバージョン互換のためExcel内部でこの記法になることがあり、そのままインポートされたと考えられます。
_xlws.を単純に文字列除去すれば解決するため、Step1のコードではformula.replace(/_xlws\.|_xlfn\./g, '')で対応しています。
発見②:プレフィックスを除いても#N/Aになる(if_empty引数の非互換性)
_xlws.を除いてFILTER(A2:E101,(D2:D101<J1)+(D2:D101>=J2),"該当なし")にしても、今度は#N/Aエラーになりました。原因は関数のシグネチャ(引数の意味)の違いです。
- Excelの
FILTER:FILTER(範囲,条件,[空の場合の値])— 3番目の引数は「該当なしのときの代替値」 - Googleスプレッドシートの
FILTER:FILTER(範囲,条件1,[条件2,...])— 3番目以降はすべて追加の条件(AND)として解釈される
"該当なし"が条件として扱われた結果、該当行ゼロ件となり、GoogleのFILTERの仕様(該当なしは常に#N/A、Excelのような受け皿がない)どおり#N/Aになっていました。
対策として、第3引数が文字列リテラルの場合だけを検出・除去する処理(rewriteFilterIfEmptyToIferror)を実装しました。単純な正規表現では条件式自体に含まれる()((D2:D101<J1)+(D2:D101>=J2))でマッチが破綻したため、括弧の深さと引用符の中かどうかを1文字ずつ追跡する自前のパーサー(findMatchingParen・splitTopLevelArgs)で対応しています。
発見③:IFERRORは「外側」ではなく「内側」に置く必要がある
単純に第3引数を削除すると、Excelにあった「該当なしのときに”該当なし”と表示する」機能が失われます。最初はIFERROR(UNIVERSAL_WRAP(FILTER(範囲,条件)), "該当なし")のようにUNIVERSAL_WRAPの外側にIFERRORを置きましたが、該当なしのケースで一瞬「該当なし」が表示された後#N/Aに戻る不安定な挙動になりました。UNIVERSAL_WRAPのようなカスタム関数は非同期呼び出しを伴うため、ネイティブ関数の暫定評価でIFERRORが機能しても、後から返るカスタム関数の実行結果(エラーの伝播)が外側のIFERRORを経由せずセルに直接反映されてしまう、というGAS特有の制約が原因と考えられます。
対策は、UNIVERSAL_WRAPにエラーを渡さないよう、FILTER自体を先にネイティブなIFERRORで包んでから渡す順序(UNIVERSAL_WRAP(IFERROR(FILTER(範囲,条件), "該当なし")))に変更することです。この順序で該当なしのケースが安定して表示され続けることを実機確認しました。Step1のコードもこの結論どおり、rewriteFilterIfEmptyToIferrorがFILTER(...)呼び出し自体をIFERROR(FILTER(...), 元の文字列)に書き換える設計にしています。

その他の細かい相違点: 展開先に既存データがある場合のエラー表示は、Excelの#スピル!ではなく#REF!になります(gas-10Step2と同じ)。スピル範囲のハイライト表示など、視覚的な挙動もExcelとよく似ています。
Step5:他の関数も検証してみた
FILTER以外の対象関数についても、同じサンプルデータで検証しました。
| 関数 | 結果 |
|---|---|
XLOOKUP | ✅ 追加対応不要。ExcelとGoogleでほぼ同じ引数設計のため、そのまま動作した |
SORT | ✅ _xlws.除去のみで正常動作 |
UNIQUE | ✅ _xlws.除去のみで正常動作 |
SEQUENCE | ✅ 追加対応不要、そのまま動作 |
EXPAND | ❌ そもそもGoogleスプレッドシートに存在しない関数だった(=EXPAND(...)単体で「不明な関数:[EXPAND]」の#NAME?) |
MAXIFS(配列条件でのスピル) | ❌ 詳細は後述 |
EXPANDについて: _xlws.除去のような対処法は「Google側に同名・同等の関数はあるが認識されない」場合にしか効きません。EXPANDはGoogleスプレッドシートに関数自体が存在しないため、技術的に対応不可能な真の限界です。
MAXIFSについて: =MAXIFS(D2:D101,E2:E101,{"北海道","青森"})のように配列(複数の都道府県)を条件にして結果をスピルさせようとしたところ、1つ目の条件(北海道)は正しく計算されるものの、2つ目以降の条件(青森)は計算自体が行われず空文字列のままになりました。配列定数を実際のセル範囲参照に変えても同じ結果だったため、原因は書き方ではなく、GoogleスプレッドシートのARRAYFORMULAが、MAXIFSのような古典的な集計関数に対して複数条件を反復計算しスピルさせる仕組みに対応していないことにあると考えられます。FILTER等はGoogleスプレッドシート自身が動的配列を返す関数として実装しているのに対し、MAXIFSは本来スカラー値を1つ返すだけの古典的な関数だからです。これも自動ラップでは解決できない真の限界です。
以上4つの非互換パターン(名前空間プレフィックス/引数シグネチャの違い/関数が存在しない/配列条件への反復計算機構がない)の整理は、まとめで振り返ります。
できること・できないことの整理
| 項目 | 結果 |
|---|---|
| 親ファイルにコードを仕込んでおけばカスタム関数のスコープ制約を回避できるか | ✅ 実機確認済み |
FILTERの_xlws.プレフィックス・if_empty引数の非互換性への自動対応 | ✅ 実機確認済み(Step4) |
XLOOKUP・SORT・UNIQUE・SEQUENCEへの対応 | ✅ 実機確認済み(Step5) |
EXPANDへの対応 | ❌ Googleスプレッドシートに関数が存在せず対応不可能 |
MAXIFS(配列条件でのスピル)への対応 | ❌ ARRAYFORMULAが複数条件の反復計算に対応しておらず不可能 |
元ネタコード(範囲一括setFormulas())の危険性 | ⚠️ 数式ではない生データを消す恐れがあるため、個別setFormula()方式を採用 |
| 「スプレッドシートを置換する」を誤って選んだ場合の影響 | ❌ データだけでなくGASのコードごと消える。必ず「新しいシートを挿入する」を選ぶ |
元のExcelファイルで#スピル!になっていたセルの安全性 ※ | ✅ ARRAY_CONSTRAINを常に取り除く方式に変更し、無関係なデータを消さないことを実機確認済み |
メニュークリックの自動化(onChangeトリガー)※ | ✅ 実機確認済み。ただしトリガー作成はonOpen()内では権限不足のため手動メニュー化が必要。changeTypeはINSERT_GRID/OTHERのどちらになるか実行のたびに一定しないため両方を対象にする |
※ 2026-09-22追記:公開後に読者からの指摘を受けて追加検証・修正した項目です。
よくある質問
Q. IFERRORはどこに置いてもいいわけではないのですか?
→ はい。カスタム関数の外側に置くと、非同期実行の都合でエラーを正しく捕まえられないことがあります(Step4参照)。エラーの原因になる部分は、ネイティブ関数の段階で先にIFERRORで解消してからカスタム関数に渡すのが安全です。
Q. 複数のExcelファイルを扱う場合、親ファイルは使い回せますか?
→ できます。インポートのたびに新しいシートが追加されるので、処理が終わったシートは適宜削除するか、「コピーを作成」で成果物を別ファイルにしてから親ファイル側を整理する運用を想定しています。
Q. EXPANDやMAXIFSを使っているExcelファイルはどうすればいいですか?
→ 今回の自動ラップでは対応できません。EXPANDは別の関数で書き直す、MAXIFSは条件ごとに数式を分けて個別に書くなど、Google側の制約に合わせて数式自体を書き換える必要があります。
まとめ
- あらかじめGASコードを仕込んだ「親ファイル」にExcelファイルを「新しいシートとして」インポートすることで、カスタム関数のスコープ制約を回避できることを実機確認した
FILTER関数には2段階の非互換性(_xlws.名前空間プレフィックス、if_empty引数の意味の違い)があり、いずれも自動処理で解消できた。ただしIFERRORはカスタム関数の内側に置く必要があるXLOOKUP・SORT・UNIQUE・SEQUENCEは大きな追加対応なしでスピルを復元できたEXPAND(関数自体が存在しない)とMAXIFSの配列条件スピル(ARRAYFORMULAが対応していない)は、自動ラップでは解決できない真の限界と判明した- 非互換性は、①名前空間プレフィックス、②引数シグネチャの違い、③関数が存在しない、④配列条件への反復計算機構がない、の4パターンに整理できる
- (2026-09-22追記) 元のExcelファイルで
#スピル!だったセルは、ARRAY_CONSTRAINの申告サイズを信用せず常に取り除く方式に変更し、無関係なデータを消さないことを確認した。またonChangeトリガーで、Excelインポート後のメニュークリック自体を自動化できることも確認した
次回予告
今回の運用フローは「新しいシートとしてインポートする」という手動の一手間が必要でした。次回は、この自動ラップの仕組みをDrive経由方式の完全自動アップロードパイプライン(HTMLダイアログでファイルを選択するだけで変換〜転記まで完了する仕組み)に組み込めるかを検証します。
サンプルファイルについて
検証に使用したテスト用スプレッドシートはこちらからコピーできます。GASのスクリプトはスプレッドシートに紐づいた状態(コンテナバインド型)で保存されているため、このシートをコピーすればスクリプトごとそのまま複製されます。シート内には手順と、Step5で検証した各関数の結果一覧表も掲載しています。
↑ リンク先ファイルのURL末尾を/copyにした「コピー用リンク」にしています。クリックするだけでご自分のGoogleドライブにスプレッドシートのコピーが複製(GASスクリプトごと)されるようになっています。
関連記事
当サイトの記事で使用したVBAなどのサンプルをDLできます
ダウンロードページへは下のカードをクリックすればジャンプできます。
よろしければご利用ください!

