You wrote something useful in Google Apps Script and you want other people to use it. There are two ways they will: paste your code into their own project, or add it as a library and call it by an identifier. The catch is that those two audiences want different shapes of the same code, and if you are not careful you end up hand-maintaining two copies that slowly drift apart. Here is how to serve both from one source, and the one Apps Script rule that makes the naive approach quietly fail.
The rule nobody tells you
When a project is added as an Apps Script library, only top-level function declarations are exposed to the consumer. A top-level class, const, or let is invisible. This surprises people because it works fine when you paste the same code, where everything shares one scope.
So the clean, modern-looking version of a tool is exactly the version that gives a library consumer nothing:
class Extractor { /* ... */ } // invisible as a library
const run = () => { /* ... */ }; // also invisible
Add that as a library called Crux and Crux.Extractor is undefined. The consumer has nothing to call.
The facade
The fix is small. Keep your implementation in a class, mark it private with a trailing underscore (the Apps Script convention for "do not expose"), and put the public surface in a plain function declaration:
// Exposed to library consumers as Crux.extract(...)
function extract(config) {
return new Extractor_(config).run();
}
// Private: the trailing underscore hides it from consumers
class Extractor_ {
constructor(config) { /* ... */ }
run() { /* ... */ }
}
Now the library surface is exactly one function, extract(), and the class stays an implementation detail. One more thing worth knowing: a library runs under the consumer's authorization, so their project, not yours, is what authorizes the scopes (UrlFetchApp, SpreadsheetApp, whatever you touch) on first run.
The paste audience wants something different
A library consumer calls extract() from their own function. But someone pasting the code wants a ready entry point to edit and bind a trigger to, something like:
function main() {
return extract({ urls: [...], spreadsheetId: "...", apiKey: "..." });
}
You do not want that main() in the library (it would just be dead scaffolding a consumer never calls), but you do want it in the pasted version. That is the whole tension: the two artifacts differ by exactly one function, the entry point.
The wrong fix is to keep two full files in sync by hand. The logic is the expensive part, and it should exist once.
Generate the second artifact
Keep the source split by concern:
src/index.jsis the library:extract()plus the private class.src/main.partial.jsis just the paste entry point,main().
Then a build step concatenates them into the copy-paste file. It is about fifteen lines, no dependencies:
const fs = require("fs");
const lib = fs.readFileSync("src/index.js", "utf8");
const entry = fs.readFileSync("src/main.partial.js", "utf8");
fs.writeFileSync("dist/standalone.js", lib.trimEnd() + "\n\n" + entry.trimStart());
src/index.js is what you deploy as the library. dist/standalone.js is what a paste user copies. The shared logic lives once, the entry point lives once, and nothing is duplicated, so nothing can drift.
The last piece is a guard so the generated file cannot go stale. A one-line CI check rebuilds and fails if the committed output differs:
- run: node build.js
- run: git diff --exit-code dist/ # fails if dist/ was not rebuilt and committed
Now a contributor who edits src/ but forgets to rebuild gets a red check instead of shipping a copy-paste file that no longer matches the library.
The shape, in general
This pattern is not specific to Apps Script. Any time you need two artifacts from one codebase, a bundled build and a single-file drop, a library and a CLI, do not hand-maintain twins. Pick one canonical source, generate the rest, and make the freshness check part of CI so the generated files cannot silently fall behind.
The tool I pulled these examples from is a Chrome UX Report to Google Sheets extractor, open source and published as a library. The facade above is its real public surface. To consume it, open Libraries in your own Apps Script project, paste the Script ID below, look it up, pick a version, and set the identifier to Crux:
1EvW_5vsMBlFpt2fP0xyTmXnxV6PCbcGAd9S8UCIQMNmCCpQY15eRrH7v
The single exposed function is then Crux.extract(), exactly as the facade promised, while the private Extractor_ class stays invisible:
async function run() {
return Crux.extract({ urls, spreadsheetId, apiKey });
}
The source and the generated copy-paste build are at github.com/jadedm/crux_apps_script. If you want the extractor itself rather than the packaging, that is a separate post: Track Core Web Vitals in a Google Sheet, without BigQuery.