Bootgs Quickstart
Available files
assets/tsconfig.appsscript.json— startingtsconfig.json, see TypeScript configuration below.assets/appsscript.json— starting manifest, see Manifest below.scripts/check-latest-version.sh— checks the current publishedbootgs/apps-script-utilsversions against what's installed. Run with--helpfor options.
Install
npm install bootgs
Before scaffolding, check what's actually current — this skill is written against a specific release, and bootgs moves faster than any doc snapshot:
scripts/check-latest-version.sh
(--json for machine-readable output, --help for the full reference.) Queries the npm registry for the latest published bootgs and apps-script-utils, and compares against whatever's in your local node_modules if present. A version gap doesn't necessarily mean this skill's guidance is wrong, but it's the first thing to check before assuming a decorator or option documented here doesn't exist.
apps-script-utils and reflect-metadata come in transitively — bootgs depends on both directly. Do not add your own import "reflect-metadata"; bootgs's own entry point already does the side-effect import once, and a second import anywhere is harmless but redundant.
Requires Node >=22.14.0 to build (matches bootgs's own engines field) — CI images pinned to an older Node will fail to install, not just run.
TypeScript configuration
Copy assets/tsconfig.appsscript.json as your starting point. The load-bearing options:
| Option | Value | Why |
|---|---|---|
experimentalDecorators |
true |
bootgs decorators (@RestController, @Injectable, @Get, ...) require the legacy decorator proposal, not TC39 stage-3 decorators. |
emitDecoratorMetadata |
true |
DI resolves constructor parameter types from emitted metadata (reflect-metadata). Without this, @Inject still needs an explicit token, but implicit type-based resolution silently breaks. |
useDefineForClassFields |
false |
Critical. If left at the modern default (true), class fields are defined via Object.defineProperty after the constructor body runs, which overwrites values that decorator-based DI already injected into this. Symptom: injected services are undefined at runtime with no compile error. |
target / module |
ESNext |
GAS's V8 runtime supports modern syntax; your bundler (not tsc) is what has to emit GAS-compatible output. |
types |
include "google-apps-script" |
Native GoogleAppsScript.* ambient types for event objects, services, etc. |
Manifest (appsscript.json)
Copy assets/appsscript.json as a baseline and trim oauthScopes to what you actually call — GAS enforces the declared scope list at authorization time, and an over-broad list just adds friction to the consent screen.
Non-negotiable fields:
runtimeVersion: "V8"— required; bootgs uses class syntax and decorators that don't run on the legacy Rhino engine.webapp.executeAs/webapp.access— only needed if you exposedoGet/doPostas a deployed web app (REST controllers). Skip this block for menu/sidebar-only add-ons.exceptionLogging: "STACKDRIVER"— routes uncaught errors to Cloud Logging; without it you're limited to the old Stackdriver-less execution log, which drops on script deletion.
Directory layout
bootgs doesn't enforce a layout, but the framework's own decorator vocabulary (@RestController, @Injectable/@Service/@Repository) maps directly onto a layered structure:
src/
├── controller/ # @RestController / @Controller classes — HTTP or native-event entry points
├── service/ # @Injectable() business logic, injected into controllers
├── repository/ # @Injectable() (or @Repository()) data access — SpreadsheetApp, PropertiesService, etc.
├── domain/ # DTOs, entities, enums — no bootgs decorators, no dependency on the layers above
├── exceptions/ # AppException/HttpException subclasses + any @ControllerAdvice
└── main.ts # entry point — see below
Controllers must only call services, services must only call repositories — bootgs won't enforce this for you (there's no module system to scope it). See bootgs-architecture for the full layering rules, an Interface+Impl convention, and a working eslint-plugin-boundaries config that enforces it mechanically; bootgs-validation for parameter validation inside controllers; bootgs-openapi for keeping a spec in sync with this layer.
Entry point wiring
GAS calls global functions (doGet, doPost, onOpen, onInstall, ...) directly — there's no process you control the startup of. Every one of those globals must construct an app instance and delegate to it:
import { createApp, createAsyncApp, BootApplication, AsyncBootApplication, Newable } from "bootgs";
import { WidgetController } from "./controller/WidgetController";
import { MenuController } from "./controller/MenuController";
import { WidgetServiceImpl } from "./service/WidgetServiceImpl";
import { WidgetRepositoryImpl } from "./repository/WidgetRepositoryImpl";
const providers: Newable[] = [WidgetServiceImpl, WidgetRepositoryImpl];
// Menu-only entry points can pass a smaller controller set than the REST entry points.
export function onOpen(event: GoogleAppsScript.Events.SheetsOnOpen) {
const app: BootApplication = createApp({ controllers: [MenuController], providers });
app.onOpen(event);
}
export function onInstall(event: GoogleAppsScript.Events.AddonOnInstall) {
const app: BootApplication = createApp({ controllers: [MenuController], providers });
app.onInstall(event);
}
// Synchronous app — use only if no controller method returns a Promise.
export function doGet(event: GoogleAppsScript.Events.DoGet) {
const app: BootApplication = createApp({ controllers: [WidgetController], providers });
return app.doGet(event);
}
// Async app — use if any controller method is `async` or returns a Promise
// (e.g. it calls UrlFetchApp-wrapping code that you've promisified).
export async function doPost(event: GoogleAppsScript.Events.DoPost) {
const app: AsyncBootApplication = createAsyncApp({ controllers: [WidgetController], providers });
return app.doPost(event);
}
Gotchas:
- A new
createApp/createAsyncAppcall per global function is normal and cheap — GAS re-executes the whole file on every invocation anyway (no persistent process), so there's no shared-instance benefit to hoist it further. ApplicationConfig.apiPrefixdefaults to"/api". Every controller path is mounted under it unless you override it (createApp({ controllers, providers, apiPrefix: null })to disable, or a custom string). Forgetting this is the most common "route not found" cause — seebootgs-client.- Menu callbacks:
app.onMenuis aProxy— any property access (app.onMenu.myCallback) dispatches anAppsScriptEventnamedmyCallbackto a matching handler. You don't need to enumerate menu callback names anywhere except where you build theMenuUI itself. ProvideracceptsuseClass/useValuetoday.useFactoryanduseExistingare declared in the types but not implemented (resolve tonullat runtime as of the current release) — don't design around them yet.
Build & deploy
tsc alone does not produce GAS-runnable output — GAS needs plain top-level function exports it can call by name, not an ES module graph. Use tsc only for type-checking (and .d.ts emit if you publish types), and a bundler (esbuild, Vite, Webpack — pick one) to produce the final bundle clasp pushes.
{
"scripts": {
"typecheck": "tsc -p tsconfig.appsscript.json --noEmit",
"build": "npm run typecheck && <bundler> build",
"push": "clasp push -f",
"deploy": "npm run build && npm run push"
}
}
See apps-script-clasp-workflow for .clasp.json, versioned deployments, and multi-environment setups.
Verification checklist
-
useDefineForClassFields: falseis set — otherwise DI fails silently. -
runtimeVersion: "V8"is set inappsscript.json. - Every GAS global entry point (
doGet,doPost,onOpen,onInstall, ...) constructs its own app instance and delegates — none are left as raw framework-less functions. -
oauthScopesin the manifest match only the services actually called. -
npm run typecheckpasses before bundling.