SpreadsheetApp.getUi() only exists when a user has the spreadsheet open. Time-driven and many installable triggers are headless — calling getUi() throws and aborts the run. getUiSafe returns the UI or null so onOpen menus and shared helpers don't blow up overnight jobs.
What you'll need
- A container-bound Sheets script (UI is spreadsheet-scoped)
- Code shared between
onOpen/ menus and time-driven / installable triggers - A habit of branching on
uiinstead of assuming a dialog works
How to use this snippet
/**
* Return SpreadsheetApp.getUi() when a UI exists; otherwise null.
* Safe for time-driven / installable headless executions.
* @return {GoogleAppsScript.Base.Ui|null}
*/
function getUiSafe() {
try {
return SpreadsheetApp.getUi();
} catch (e) {
return null;
}
}
Example
function onOpen() {
var ui = getUiSafe();
if (!ui) return;
ui.createMenu('Ops')
.addItem('Run nightly sync now', 'nightlySync')
.addToUi();
}
function nightlySync() {
// works from menu OR time-driven trigger
var result = syncLeads_();
var ui = getUiSafe();
if (ui) {
ui.alert('Sync done', 'Rows: ' + result.count, ui.ButtonSet.OK);
} else {
console.log('nightlySync ok', result);
}
}
Tips:
- Never put
getUi()at the top of a shared library function without a guard. - Simple
onOpenis UI-context; installableonOpenstill has UI when a user opens the file — time-driven does not. - Use
ui.alert/ menus only after the null check; log or email for headless paths. - Pair with
emailErrorAlertwhen headless failures need a human.
Tip: NitroGAS Co-Pilot is handy for splitting "menu path" vs "trigger path" in one function without duplicating the real work.
Happy Coding!
