// Sheets · Apps Script

Wrap SpreadsheetApp in a class for cleaner scripts.

Stop calling getSheetByName on every line. A small Apps Script class caches the Sheet object in its constructor and owns its own range logic — here is how to write one without tripping over file-order ReferenceErrors.

I keep calling SpreadsheetApp.getActiveSpreadsheet().getSheetByName() in every function and I want to consolidate that logic somewhere it can be reused without copy-pasting.

The script

copy · paste · trigger
SheetWrapper.gs
Apps Script
// SheetWrapper — cache sheet + own range logic in one place
class SheetWrapper {
  constructor(sheetName) {
    this.sheet = SpreadsheetApp.getActiveSpreadsheet()
                               .getSheetByName(sheetName);
    if (!this.sheet) throw new Error('Sheet not found: ' + sheetName);
  }

  getDataRange() {
    return this.sheet.getDataRange().getValues();
  }

  appendRow(rowArray) {
    this.sheet.appendRow(rowArray);
  }

  clearContents() {
    this.sheet.getDataRange().clearContent();
  }
}

// Always instantiate inside a function, not at the top level.
function runReport() {
  const orders = new SheetWrapper('Orders');
  const rows = orders.getDataRange();
  Logger.log('Row count: ' + rows.length);
}

Need a variant? Gnaw writes a custom version from one sentence — fields, triggers, edge cases handled.

Walkthrough

What the constructor is actually doing

The single call to getActiveSpreadsheet().getSheetByName() happens once, in the constructor, and the result sits on this.sheet for the lifetime of the object. Every method that follows reads from that cached reference instead of going back to SpreadsheetApp. In a script that touches the same sheet a dozen times per run, that is a dozen fewer service calls — Apps Script's quota for Spreadsheet reads is 20,000 per day on consumer Workspace, so it adds up on high-frequency triggers.

The throw on a missing sheet is worth keeping. Without it, every downstream method call produces a cryptic 'Cannot read property of null' at the .sheet access site rather than a clear 'Sheet not found: Orders' at construction time. The first time I skipped this guard I spent twenty minutes tracing a null back up to a renamed tab.

File order and the hoisting trap

Standard functions in Apps Script are hoisted: you can call myFunction() from a line that appears above myFunction's definition in the same file, or even from a file that loads before the file containing the definition. Classes are not hoisted. The V8 runtime processes .gs files alphabetically by filename; if SheetWrapper.gs sorts after the file that calls new SheetWrapper(), the class does not exist yet and you get ReferenceError: SheetWrapper is not defined.

The fix is simple: never instantiate a class at the top level of a .gs file. Put new SheetWrapper(...) inside a function body, as shown in runReport() above. Functions execute lazily — the class will have been defined by the time any trigger or menu item fires the function. Alphabetical file ordering is still the underlying mechanism Apps Script uses, so naming your class file starting with a letter that sorts early (like 'A_' or '00_') is a common workaround, but relying on lazy instantiation is cleaner and survives file renames.

Extending the pattern to multiple sheets

Because the sheet name is a constructor argument, you get one class that covers every tab in the workbook. Passing 'Orders', 'Invoices', or 'Config' at call time produces three independent wrappers, each with its own cached Sheet reference. Methods like getDataRange() and appendRow() operate on whichever sheet was passed in.

When a method needs a named range rather than the full data region, add it directly: this.sheet.getRange('B2:D50') inside a dedicated method keeps the range string in one place. If the tab is renamed or the layout shifts, you update the class, not every caller. I keep one SheetWrapper.gs per project and add a method any time I find myself writing the same getRange() call twice.

Want a custom version?

Describe your sheet and the rule you want. Gnaw writes the Apps Script — fields, triggers, edge cases — in one shot.

FAQ

4 questions
Why does Apps Script throw ReferenceError for my class but not my functions?
Functions are hoisted to the top of their scope before execution begins; classes are not. Apps Script loads .gs files in alphabetical order and evaluates each one top-to-bottom. If the file that calls new MyClass() loads before the file that defines MyClass, the class binding does not exist yet. Instantiate inside a function body rather than at module scope and the problem disappears, because function bodies run after all files have been parsed.
Can I use this pattern with SpreadsheetApp.openById() instead of getActiveSpreadsheet()?
Yes. Replace the getActiveSpreadsheet() call in the constructor with SpreadsheetApp.openById('your-spreadsheet-id') and the rest of the class stays identical. This is useful for scripts that run as standalone projects targeting a specific spreadsheet rather than a bound container script.
Does caching the Sheet object in the constructor cause stale data if another user edits the sheet mid-run?
No. The constructor caches the Sheet service object, which is a live reference to the sheet metadata. Calls like getDataRange().getValues() fetch current cell data at the time of the call. What you are avoiding is the overhead of re-resolving the sheet by name on every call, not freezing the data.
Is there a limit to how many methods I should put on a wrapper class?
There is no hard quota, but keep methods tightly scoped to things a sheet object would naturally own: reading ranges, writing rows, clearing content, finding a row by key. Business logic (calculating totals, sending emails, calling external APIs) belongs in caller functions that receive data from the wrapper, not inside it. Once a class method needs to know about another sheet or a global config, it has grown past its natural boundary.