TypeScript for Google Apps Script
Write and compile TypeScript for Google Apps Script with @types/google-apps-script and clasp, configure tsconfig.json, emit .gs code, and upload.
Build and review the exact Apps Script upload files, preserve a discoverable entrypoint, and diagnose source errors separately from bad spreadsheet input.
Compile three TypeScript source files into one Apps Script file, then stage that file with its manifest in a separate upload directory. The program reads three fictional requests, validates their cell values, and logs how many requests are open and how many points they represent. It does not change cells or call an external service.
Use Develop Apps Script locally as the prerequisite for account, target, and Git ownership. This project has its own complete sources and fixture; do not copy the earlier request-summary code into it. You can build and inspect the project without authorizing a Google account. Uploading and running it require a disposable spreadsheet and bound script.
Separate the compiler from the runtime
TypeScript adds a checker and a compilation step to the local workflow. Apps Script still runs JavaScript. The installed declarations describe names such as SpreadsheetApp; they do not provide a local implementation of those services.
| File or tool | Runs or takes effect where | Purpose |
|---|---|---|
src/*.ts | Checked and compiled on your computer | Authored Apps Script logic with type annotations |
scripts/build.mjs | Node.js on your computer | Run the compiler and stage its output |
dist/Code.gs | Apps Script, after upload | Generated JavaScript with ordinary global functions |
Root appsscript.json | Read locally by the build | Authored manifest input |
dist/appsscript.json | Apps Script, after upload | Exact copy of the manifest input |
| clasp | Node.js, using the selected Google account when an operation needs it | Inspect or replace a selected script project's content |
This is a complete compiler and concatenation workflow, not an npm-module bundler. TypeScript's outFile combines global, non-module source files. It cannot bundle CommonJS or ES modules. The example uses no server-side import, export, or require.
The Apps Script V8 runtime does not support ES module imports and is neither Node.js nor a browser. Giving an output file a .gs extension cannot make Node's fs, process, or module loader work there. The node: imports in the build script are legitimate because that file runs only on your computer and is never uploaded.
Assemble the complete input project
Use a maintained Node.js 22 release at least 22.11.0, or Node.js 24, with npm and a local editor. The terminal examples use a POSIX-style shell, such as macOS Terminal or Git Bash on Windows. Start in a new directory outside existing repositories and Apps Script checkouts:
mkdir typescript-build
cd typescript-build
mkdir src scripts Save each project file from this chapter into its matching path. Preserve the leading dots in the two ignore files; remove a browser-added .txt suffix if necessary. The authored input layout is:
typescript-build/
package.json
tsconfig.json
clasp.example.json
.claspignore
.gitignore
appsscript.json
requests.csv
scripts/
build.mjs
src/
request-types.ts
request-summary.ts
entrypoints.ts The input layout has no dist directory and no lockfile. Installation creates node_modules and a real package-lock.json; the build creates dist. Do not put source, credentials, or any file you want to keep in dist: the build owns and clears that directory.
Install the pinned local tools
{
"name": "book-typescript-request-summary",
"version": "1.0.0",
"private": true,
"engines": {
"node": ">=22.11.0"
},
"scripts": {
"build": "node scripts/build.mjs",
"clean": "node scripts/build.mjs --clean",
"tsc": "tsc",
"clasp": "clasp --user book-test",
"target": "clasp --user book-test --project .clasp.json --ignore .claspignore"
},
"devDependencies": {
"@google/clasp": "3.4.1",
"@types/google-apps-script": "2.0.13",
"typescript": "5.9.3"
}
}
The direct dependencies are pinned to published packages: TypeScript 5.9.3, Apps Script declarations 2.0.13, and clasp 3.4.1. The declaration package contains types, not Apps Script service implementations. All three are development dependencies; none is copied into the remote script.
Review package.json, then run:
node --version
npm --version
npm install --include=dev --ignore-scripts
npm run tsc -- --version
npm run clasp -- --version
npm ls --depth=0 Expect Version 5.9.3 from the compiler, 3.4.1 from clasp, and the three pinned versions in the dependency list. Stop on an installation error or a different version. Do not replace a missing local executable with an unpinned npx download or a global installation.
The first install resolves transitive dependencies and generates package-lock.json. Exact direct versions do not freeze that entire graph. Review and retain the generated lockfile with your source, recording the Node.js and npm versions used. On later clean checkouts with that lockfile, use:
npm ci --include=dev --ignore-scripts npm's ci contract requires a matching lockfile, does not update it, and replaces an existing node_modules directory. Do not use ci before the first lockfile exists. --ignore-scripts prevents dependency lifecycle scripts from running during installation; it does not prevent a later explicit npm run build or clasp command.
Keep the compiler's global environment narrow
{
"compilerOptions": {
"target": "ES2020",
"module": "None",
"moduleDetection": "legacy",
"lib": ["ES2020"],
"types": ["google-apps-script"],
"strict": true,
"noEmitOnError": true,
"forceConsistentCasingInFileNames": true,
"newLine": "lf",
"outFile": "dist/Code.js"
},
"files": [
"src/request-types.ts",
"src/request-summary.ts",
"src/entrypoints.ts"
]
}
files names the complete input set in order. The type definitions come first, then validation and summary functions, then the entrypoint. Saving a new file under src does not include it; add it to this list. The files share one global scope, so duplicate global names are a source error.
module: "None" with moduleDetection: "legacy" keeps these import-free files as global scripts. outFile combines their emitted JavaScript into dist/Code.js. No module wrapper hides the entrypoint. Do not change this to CommonJS or add export {} as an editor workaround: that would change the program's module contract.
types: ["google-apps-script"] selects the Apps Script declarations instead of loading every visible @types package. The TypeScript option controls global declaration inclusion, not runtime availability. lib: ["ES2020"] supplies standard JavaScript declarations without the browser DOM. These settings help catch accidental use of Node or browser globals in server code; they are not proof that every possible ES2020 API exists in Apps Script.
target: "ES2020" selects the emitted JavaScript syntax level. The example uses V8-supported function declarations, const, let, template literals, arrays, objects, and Set, so it needs no runtime helper or polyfill. strict checks the typed program. noEmitOnError prevents compiler output when errors are reported, but does not by itself delete an older successful build. The staging script handles that stale-output risk.
Validate values before giving them domain types
The fixture has exact headers ID, Status, and Points. Each ID must match REQ- followed by three digits and be unique. Status must be Open, Hold, or Done. Points must be a number that is an integer from 0 through 100. The program accepts at most 100 data rows; blank interior rows are errors, not records silently dropped from the total.
ID,Status,Points
REQ-001,Open,3
REQ-002,Hold,2
REQ-003,Open,5
The type aliases and interfaces describe records the program has already validated:
type TypedRequestStatus = 'Open' | 'Hold' | 'Done';
interface TypedRequest {
id: string;
status: TypedRequestStatus;
points: number;
}
interface TypedRequestSummary {
requestCount: number;
openCount: number;
openPoints: number;
heldCount: number;
doneCount: number;
}
TypedRequestStatus is a union: a value must be one of its three literal strings. An interface describes an object's required properties. Neither creates a runtime object or examines a spreadsheet. TypeScript erases type annotations when emitting JavaScript.
Google's Range.getValues() documentation says cells can contain numbers, strings, booleans, or dates, and empty cells become empty strings. A colleague can enter two, a date, or an unsupported status after your successful local build. A type assertion such as value as TypedRequest would only instruct the checker to trust you; it would not reject any of those inputs.
The parser therefore receives unknown values. unknown means the program must check a value before treating it as a particular type. The typeof, exact-status, integer, range, and ID checks remain in the emitted JavaScript. Once they succeed, TypeScript can narrow the values enough to construct a TypedRequest without a cast.
function readTypedRequest_(row: readonly unknown[], rowNumber: number): TypedRequest {
const id = row[0];
const status = row[1];
const points = row[2];
if (typeof id !== 'string' || !/^REQ-[0-9]{3}$/.test(id)) {
throw new Error(`Row ${rowNumber}: ID must have the form REQ-001.`);
}
if (status !== 'Open' && status !== 'Hold' && status !== 'Done') {
throw new Error(`Row ${rowNumber}: Status must be Open, Hold, or Done.`);
}
if (typeof points !== 'number' || !Number.isInteger(points) || points < 0 || points > 100) {
throw new Error(`Row ${rowNumber}: Points must be an integer from 0 through 100.`);
}
return { id, status, points };
}
function summarizeTypedRequests_(values: readonly (readonly unknown[])[]): TypedRequestSummary {
if (values.length < 1 || values.length > 101) {
throw new Error('Requests must contain a header and at most 100 data rows.');
}
const header = values[0];
if (header.length !== 3 || header[0] !== 'ID' || header[1] !== 'Status' || header[2] !== 'Points') {
throw new Error('Requests headers must be ID, Status, Points in A1:C1.');
}
const seenIds = new Set<string>();
let requestCount = 0;
let openCount = 0;
let openPoints: number = 0;
let heldCount = 0;
let doneCount = 0;
for (let index = 1; index < values.length; index += 1) {
const request = readTypedRequest_(values[index], index + 1);
if (seenIds.has(request.id)) {
throw new Error(`Row ${index + 1}: duplicate ID ${request.id}.`);
}
seenIds.add(request.id);
requestCount += 1;
if (request.status === 'Open') {
openCount += 1;
openPoints += request.points;
} else if (request.status === 'Hold') {
heldCount += 1;
} else {
doneCount += 1;
}
}
return { requestCount, openCount, openPoints, heldCount, doneCount };
}
The summary works in memory and returns one object. It does not log a partial total: if a later row is invalid or repeats an ID, it throws before returning. The caller logs only after the whole bounded input passes validation.
Keep the callable function at the top level
function logTypedRequestSummary(): void {
const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
if (!spreadsheet) {
throw new Error('Run this function from the script bound to the test spreadsheet.');
}
const sheet = spreadsheet.getSheetByName('Requests');
if (!sheet) {
throw new Error('Create a sheet named Requests in the test spreadsheet.');
}
const lastRow = sheet.getLastRow();
if (lastRow < 1 || lastRow > 101 || sheet.getLastColumn() !== 3) {
throw new Error('Requests must have three columns, a header, and at most 100 data rows.');
}
const values: unknown[][] = sheet.getRange(1, 1, lastRow, 3).getValues();
const summary = summarizeTypedRequests_(values);
Logger.log(JSON.stringify(summary));
}
logTypedRequestSummary is the function you run from the Apps Script editor. It is an ordinary top-level function declaration, with no Node export and no generated module wrapper around it. Apps Script's V8 function detection recognizes this form.
The entrypoint verifies the bound spreadsheet, sheet name, three-column layout, and row bound before reading cells. It reads A1:C101 at most, in one getValues() call, then delegates to the pure parser and summary. The code annotates the values as unknown[][] even though service declarations may expose a less restrictive type, because the cell contents are not trusted application records.
The two helpers are also global function declarations because this is a global-script project. Their trailing underscores mark them as implementation helpers by convention; that spelling is not a security boundary. Do not select them for the exercise: they require arguments. Avoid top-level service calls, since global initialization can run before the requested entrypoint.
{
"timeZone": "Etc/UTC",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/spreadsheets.currentonly"
]
}
The manifest selects V8 and requests spreadsheets.currentonly. That scope permits access to the current spreadsheet; it is not a read-only scope. The absence of write calls makes this program read-only. The manifest does not authorize clasp to manage source code, and installing type declarations grants no Google permissions.
Build the exact upload directory
import { spawnSync } from 'node:child_process';
import { copyFileSync, lstatSync, mkdirSync, renameSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const projectRoot = fileURLToPath(new URL('../', import.meta.url));
const outputDirectory = join(projectRoot, 'dist');
const args = process.argv.slice(2);
if (args.length > 1 || (args.length === 1 && args[0] !== '--clean')) {
throw new Error('Use npm run build or npm run clean; output paths are not configurable.');
}
function cleanOutput() {
const existing = lstatSync(outputDirectory, { throwIfNoEntry: false });
if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) {
throw new Error('Refusing to clean dist: it must be an ordinary generated directory, not a file or symlink.');
}
rmSync(outputDirectory, { recursive: true, force: true });
}
cleanOutput();
if (args[0] === '--clean') {
console.log('Removed only this project\'s generated dist directory.');
} else {
try {
mkdirSync(outputDirectory);
const compilation = spawnSync(
process.execPath,
[join(projectRoot, 'node_modules/typescript/bin/tsc'), '--project', join(projectRoot, 'tsconfig.json')],
{ cwd: projectRoot, stdio: 'inherit' },
);
if (compilation.error) {
throw compilation.error;
}
if (compilation.status !== 0) {
throw new Error(`TypeScript compilation failed (${compilation.signal ?? compilation.status}). No upload output retained.`);
}
renameSync(join(outputDirectory, 'Code.js'), join(outputDirectory, 'Code.gs'));
copyFileSync(join(projectRoot, 'appsscript.json'), join(outputDirectory, 'appsscript.json'));
console.log('Built dist/Code.gs and dist/appsscript.json. No upload or Apps Script execution performed.');
} catch (error) {
cleanOutput();
throw error;
}
}
The Node script anchors paths to its own project directory, not an arbitrary current working directory. It refuses a file or symlink at dist, removes ordinary previous output, invokes the installed compiler directly through the current Node executable, and waits for completion. Node's spawnSync supplies that synchronous completion boundary. There is no shell-based compiler lookup or automatic package download.
After a successful compile, the script renames the real dist/Code.js to dist/Code.gs and copies the manifest input. TypeScript has already compiled the file; the rename changes only its extension. A caught compilation or staging failure removes partial output and exits with an error. A forcibly interrupted process may leave partial files, so treat interruption as failure, clean, and rebuild before considering an upload.
Run:
npm run build A successful build should report that it created the two files and performed no upload or Apps Script execution. Open dist in your editor. Its complete successful contents must be:
dist/
Code.gs
appsscript.json Inspect the files this build generated:
Code.gscontains the ordinary top-level declarationsreadTypedRequest_,summarizeTypedRequests_, andlogTypedRequestSummary.Type aliases, interfaces, parameter type annotations, and return type annotations are gone. Runtime checks and error messages remain.
There is no
import,export,require,module.exports, Nodeprocess, or module wrapper in the emitted program.node_modulesandscripts/build.mjsare not part of it.dist/appsscript.jsonhas the same bytes as the root manifest, including V8 and the current-spreadsheet scope. There are no.ts, declaration, source-map, CSV, or npm files indist.
Do not run Code.gs with Node to test SpreadsheetApp: Node does not provide that service. A compiler success establishes neither Google authorization nor successful execution. Editor line numbers after upload refer to emitted Code.gs; use those lines to locate the corresponding TypeScript function, then repair src and rebuild. Editing generated output loses the repair on the next build.
Keep uploads, source history, and credentials separate
{
"scriptId": "",
"rootDir": "dist",
"scriptExtensions": [".gs"]
}
**/*
!Code.gs
!appsscript.json
rootDir is dist, not src and not the project root. The clasp file-selection implementation applies ignore patterns relative to that upload root. The allowlist names Code.gs and appsscript.json, without a dist/ prefix. The template's empty Script ID selects no project.
clasp 3.4.1 does not transpile TypeScript. Do not upload the TypeScript source in place of a build. If dist is absent, a push does not create it. Keep build, review, and push separate; no package script automatically uploads.
# Dependencies and compiler-owned output.
node_modules/
/dist/
/remote-snapshots/
*.log
*.tmp
*.tsbuildinfo
.DS_Store
# Live targets and credentials are local, not source.
.clasp.json
.clasp.*.json
.clasprc.json
.clasprc.*.json
.env
.env.*
.npmrc
credentials.json
credentials-*.json
client_secret*.json
service-account*.json
*.pem
*.key
Git should retain the authored .ts files, Node build script, manifest input, fixture, inert target template, package files, reviewed generated lockfile, and ignore rules. It should not retain generated dist, node_modules, live .clasp.json, snapshots, or credentials. Follow the explicit-path staging and review procedure in Develop Apps Script locally; do not publish this example repository as part of the exercise. Ignore patterns are precautions, not secret detectors, and do not remove files already tracked by Git.
Bind the upload to an approved test project
Do not reuse a production .clasp.json.
Create a new blank spreadsheet named
Typed request summary - test, with no existing automation. Rename its first sheetRequests.Paste
requests.csvintoA1, split the text into columns using Comma, and verifyA1:C4. The Points cells must be numeric3,2, and5. Apps Script does not read the CSV, and clasp does not import it into Sheets.Open Extensions > Apps Script from that spreadsheet. Name the bound project
Typed request summary - test. In Project Settings, obtain its Script ID. This is not the spreadsheet file ID or a deployment ID.Enable Google Apps Script API in Apps Script user settings (
script.google.com/home/usersettings) if necessary. This account-level setting and OAuth authorization are separate from the runtime manifest.Use the named clasp credential to authorize and inspect the account, then create the live target file:
npm run clasp -- login
npm run clasp -- show-authorized-user
cp clasp.example.json .clasp.json If book-test was already authorized in the prerequisite, inspect that identity before deciding whether another login is needed. The credential label is not a sandbox. Keep its token store, normally ~/.clasprc.json, outside source control and out of screenshots or reports.
In .clasp.json, fill the empty scriptId string with the test Script ID. Leave rootDir as dist and scriptExtensions as [".gs"]; leave the template unchanged. Compare the saved ID with Project Settings, and reopen the script from the test spreadsheet to confirm its container. Account selection and target selection are distinct checks.
Inspect remote head before replacing it
Before the first push, inspect a fresh snapshot without pulling over either TypeScript inputs or build output. Create a new snapshot directory; if it already exists, choose a different unused name rather than overwrite it:
mkdir -p remote-snapshots
mkdir remote-snapshots/before-typescript-upload
cp .clasp.json remote-snapshots/before-typescript-upload/.clasp.json Edit only the copied configuration so its rootDir is ".", retaining the verified Script ID. Then pull into that snapshot:
npm run clasp -- --project remote-snapshots/before-typescript-upload/.clasp.json pull Inspect all downloaded files. A newly created bound project should contain only its initial source and manifest; unexpected code requires reconciliation with its owner before replacement. A snapshot does not lock remote editing. Coordinate a quiet upload window, and do not overwrite unreviewed changes made after the snapshot. Pulling emitted JavaScript cannot reconstruct your interfaces, annotations, or original source-file split.
After a successful current build, review its files, the manifest, the source revision, the account, and the target. Ask clasp what it would upload:
npm run target -- show-file-status Require exactly dist/Code.gs and dist/appsscript.json as upload candidates. If files are extra or missing, stop and repair the build or configuration; do not force the push. When the upload list matches, run:
npm run target -- push If a push reports an error or its response is lost, do not assume that remote head is unchanged. Take another fresh snapshot and compare it with the intended output before deciding whether to repeat the upload. This read-only program needs no deployment or trigger. The account running logTypedRequestSummary in the bound editor will separately authorize the current-spreadsheet runtime scope.
Predict, change, fail, and restore
Observe the baseline
After uploading the baseline, open the bound Apps Script editor. Select logTypedRequestSummary, click Run, and review the runtime permissions before authorizing. In Execution log, expect this summary object, apart from the editor's timestamp or log prefix:
{"requestCount":3,"openCount":2,"openPoints":8,"heldCount":1,"doneCount":0} The two open requests contribute 3 + 5 = 8 points; the held request is counted but contributes no open points. A second run with unchanged cells should produce the same summary and no cell changes. An empty data set with the three headers alone should produce zero counts; do not delete the headers to represent an empty list.
Trace a source change through the build
In src/request-summary.ts, change only openPoints += request.points; to openPoints += request.points * 2;. Predict openPoints before uploading: it should remain 8 remotely, because saving TypeScript and building locally do not change remote head.
Run npm run build, inspect the changed expression in dist/Code.gs, and review npm run target -- show-file-status again. Push the build and run the entrypoint. Expect openPoints to become 16, with the request counts unchanged. If it stays 8, check the built file, selected target, upload result, and executing project rather than editing the spreadsheet to fit the expectation.
Restore the original accumulation expression in src, rebuild, inspect, and upload the baseline again. Run it and expect 8. A local source restore alone does not restore remote head.
Make a local type error
With the accumulation restored, change only let openPoints: number = 0; to let openPoints: number = '0';. Run npm run build and expect a nonzero exit with a type error: a string initializer cannot satisfy number. Inspect the local directory; the handled failure should leave no dist output to mistake for a fresh build. Do not push after the failure.
Restore numeric 0 and build again. The two upload files should return. Because the temporary bad source was never successfully built or uploaded, the remote baseline should still run unchanged. This error came from the typed source, not from any Google authorization or cell value.
Make a runtime input error
Keep the baseline source and fixture. First change B3 from Hold to Open, without building or pushing. Predict openCount of 3, openPoints of 10, and heldCount of 0. Run the entrypoint to observe that cell data is a separate input from uploaded code. Restore B3 to Hold.
Now change C3 from numeric 2 to the text two. The local TypeScript project has not changed and can still compile. Running the uploaded function should fail with:
Row 3: Points must be an integer from 0 through 100. There should be no successful summary from that execution. In Executions, distinguish this row-validation failure from a permission or missing-sheet error. Restore C3 to numeric 2, not text forced with a leading apostrophe, and run again. Expect the full baseline summary. The surviving runtime check, not the erased number annotation, rejected the bad cell.
Know when this build design no longer fits
Adding another global-script source file is a small change: keep names distinct, add the file to tsconfig.json, rebuild, and inspect the combined output. Keep the ordinary top-level entrypoint intact. This workflow does not need module loading for its three authored source files.
Adding an npm application dependency is a different design decision. Installing it does not make its exports available to Apps Script, and outFile cannot resolve and bundle an arbitrary CommonJS or ES-module dependency graph. A larger module-based project needs a pinned bundler with an Apps Script-compatible output configuration, top-level global entrypoints, and inspection of runtime dependencies. It may still be unusable if a dependency relies on Node filesystem, browser DOM, timers, or other unavailable APIs. Choosing a bundler does not supply those APIs.
Do not add declare const require: any, fake service globals, or a runtime shim to silence the checker. Decide whether the dependency fits Apps Script before changing the build.
Remove only generated output
Restore the baseline source and B3/C3 values before finishing. Do not assume a local Git restore, npm run clean, or closing the editor changes remote head.
When no build or upload is running, remove the generated directory with:
npm run clean This command removes only the fixed dist directory beside this project's package.json; it refuses a file or symlink at that path. It does not delete source, the lockfile, credentials, the spreadsheet, or the remote script. Do not race cleanup with an upload or use dist for manually maintained files. Rebuilding from retained inputs recreates the local upload output.
Keep the source and reviewed lockfile if you will continue. Inspect any snapshots or dependency directory before deciding to remove them. If you no longer need the named book-test credential, identify it before using npm run clasp -- logout; do not delete a shared home credential store. Google-side revocation and disabling account-level API access are separate choices that can affect other tools.
Delete the disposable test spreadsheet when you no longer need it.
Next, package shared Apps Script helpers as a versioned script library.
Project files
Complete source files for this chapter’s examples.
Compile a typed request summary into an Apps Script upload directory
package.json
Download file{
"name": "book-typescript-request-summary",
"version": "1.0.0",
"private": true,
"engines": {
"node": ">=22.11.0"
},
"scripts": {
"build": "node scripts/build.mjs",
"clean": "node scripts/build.mjs --clean",
"tsc": "tsc",
"clasp": "clasp --user book-test",
"target": "clasp --user book-test --project .clasp.json --ignore .claspignore"
},
"devDependencies": {
"@google/clasp": "3.4.1",
"@types/google-apps-script": "2.0.13",
"typescript": "5.9.3"
}
}
tsconfig.json
Download file{
"compilerOptions": {
"target": "ES2020",
"module": "None",
"moduleDetection": "legacy",
"lib": ["ES2020"],
"types": ["google-apps-script"],
"strict": true,
"noEmitOnError": true,
"forceConsistentCasingInFileNames": true,
"newLine": "lf",
"outFile": "dist/Code.js"
},
"files": [
"src/request-types.ts",
"src/request-summary.ts",
"src/entrypoints.ts"
]
}
scripts/build.mjs
Download fileimport { spawnSync } from 'node:child_process';
import { copyFileSync, lstatSync, mkdirSync, renameSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const projectRoot = fileURLToPath(new URL('../', import.meta.url));
const outputDirectory = join(projectRoot, 'dist');
const args = process.argv.slice(2);
if (args.length > 1 || (args.length === 1 && args[0] !== '--clean')) {
throw new Error('Use npm run build or npm run clean; output paths are not configurable.');
}
function cleanOutput() {
const existing = lstatSync(outputDirectory, { throwIfNoEntry: false });
if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) {
throw new Error('Refusing to clean dist: it must be an ordinary generated directory, not a file or symlink.');
}
rmSync(outputDirectory, { recursive: true, force: true });
}
cleanOutput();
if (args[0] === '--clean') {
console.log('Removed only this project\'s generated dist directory.');
} else {
try {
mkdirSync(outputDirectory);
const compilation = spawnSync(
process.execPath,
[join(projectRoot, 'node_modules/typescript/bin/tsc'), '--project', join(projectRoot, 'tsconfig.json')],
{ cwd: projectRoot, stdio: 'inherit' },
);
if (compilation.error) {
throw compilation.error;
}
if (compilation.status !== 0) {
throw new Error(`TypeScript compilation failed (${compilation.signal ?? compilation.status}). No upload output retained.`);
}
renameSync(join(outputDirectory, 'Code.js'), join(outputDirectory, 'Code.gs'));
copyFileSync(join(projectRoot, 'appsscript.json'), join(outputDirectory, 'appsscript.json'));
console.log('Built dist/Code.gs and dist/appsscript.json. No upload or Apps Script execution performed.');
} catch (error) {
cleanOutput();
throw error;
}
}
src/request-types.ts
Download filetype TypedRequestStatus = 'Open' | 'Hold' | 'Done';
interface TypedRequest {
id: string;
status: TypedRequestStatus;
points: number;
}
interface TypedRequestSummary {
requestCount: number;
openCount: number;
openPoints: number;
heldCount: number;
doneCount: number;
}
src/request-summary.ts
Download filefunction readTypedRequest_(row: readonly unknown[], rowNumber: number): TypedRequest {
const id = row[0];
const status = row[1];
const points = row[2];
if (typeof id !== 'string' || !/^REQ-[0-9]{3}$/.test(id)) {
throw new Error(`Row ${rowNumber}: ID must have the form REQ-001.`);
}
if (status !== 'Open' && status !== 'Hold' && status !== 'Done') {
throw new Error(`Row ${rowNumber}: Status must be Open, Hold, or Done.`);
}
if (typeof points !== 'number' || !Number.isInteger(points) || points < 0 || points > 100) {
throw new Error(`Row ${rowNumber}: Points must be an integer from 0 through 100.`);
}
return { id, status, points };
}
function summarizeTypedRequests_(values: readonly (readonly unknown[])[]): TypedRequestSummary {
if (values.length < 1 || values.length > 101) {
throw new Error('Requests must contain a header and at most 100 data rows.');
}
const header = values[0];
if (header.length !== 3 || header[0] !== 'ID' || header[1] !== 'Status' || header[2] !== 'Points') {
throw new Error('Requests headers must be ID, Status, Points in A1:C1.');
}
const seenIds = new Set<string>();
let requestCount = 0;
let openCount = 0;
let openPoints: number = 0;
let heldCount = 0;
let doneCount = 0;
for (let index = 1; index < values.length; index += 1) {
const request = readTypedRequest_(values[index], index + 1);
if (seenIds.has(request.id)) {
throw new Error(`Row ${index + 1}: duplicate ID ${request.id}.`);
}
seenIds.add(request.id);
requestCount += 1;
if (request.status === 'Open') {
openCount += 1;
openPoints += request.points;
} else if (request.status === 'Hold') {
heldCount += 1;
} else {
doneCount += 1;
}
}
return { requestCount, openCount, openPoints, heldCount, doneCount };
}
src/entrypoints.ts
Download filefunction logTypedRequestSummary(): void {
const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
if (!spreadsheet) {
throw new Error('Run this function from the script bound to the test spreadsheet.');
}
const sheet = spreadsheet.getSheetByName('Requests');
if (!sheet) {
throw new Error('Create a sheet named Requests in the test spreadsheet.');
}
const lastRow = sheet.getLastRow();
if (lastRow < 1 || lastRow > 101 || sheet.getLastColumn() !== 3) {
throw new Error('Requests must have three columns, a header, and at most 100 data rows.');
}
const values: unknown[][] = sheet.getRange(1, 1, lastRow, 3).getValues();
const summary = summarizeTypedRequests_(values);
Logger.log(JSON.stringify(summary));
}
appsscript.json
Download file{
"timeZone": "Etc/UTC",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/spreadsheets.currentonly"
]
}
requests.csv
Download fileID,Status,Points
REQ-001,Open,3
REQ-002,Hold,2
REQ-003,Open,5
clasp.example.json
Download file{
"scriptId": "",
"rootDir": "dist",
"scriptExtensions": [".gs"]
}
.claspignore
Download file**/*
!Code.gs
!appsscript.json
.gitignore
Download file# Dependencies and compiler-owned output.
node_modules/
/dist/
/remote-snapshots/
*.log
*.tmp
*.tsbuildinfo
.DS_Store
# Live targets and credentials are local, not source.
.clasp.json
.clasp.*.json
.clasprc.json
.clasprc.*.json
.env
.env.*
.npmrc
credentials.json
credentials-*.json
client_secret*.json
service-account*.json
*.pem
*.key