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, keep the entrypoint visible to the Apps Script editor, and tell a TypeScript error from a bad spreadsheet value.
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 from a sheet, 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.
Complete Local Apps Script development with clasp first. It covers choosing the Google account, the target script, and Git setup. This project has its own complete sources and sample data, so do not copy the earlier request-summary code into it. You can build and inspect the project without authorizing a Google account. To upload and run it, you need a disposable spreadsheet and its bound script.
Separate the compiler from the runtime
TypeScript adds a type checker and a compilation step to the local workflow. Apps Script still runs JavaScript. The installed declarations describe names such as SpreadsheetApp, but 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 workflow compiles and concatenates files. It does not bundle npm modules. TypeScript's outFile combines global, non-module source files and 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. Node's fs, process, and module loader remain unavailable in a .gs file. The build script can use node: imports because it runs only on your computer and is never uploaded.
Assemble the complete input project
Use a maintained release of Node.js 22, version 22.11.0 or later, or Node.js 24. You also need 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 any existing repository or Apps Script checkout:
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, and 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 package-lock.json, and the build creates dist. The build deletes and recreates dist, so never put source, credentials, or any file you want to keep there.
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 exact published versions: TypeScript 5.9.3, Apps Script declarations 2.0.13, and clasp 3.4.1. All three are development dependencies, and 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. Pinning the direct versions does not freeze that whole graph, whereas the lockfile does. Review the generated lockfile and keep it with your source. Record the Node.js and npm versions you used. On a later clean checkout that has the lockfile, use:
npm ci --include=dev --ignore-scripts npm's ci command 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 first, then the validation and summary functions, then the entrypoint. Saving a new file under src does not include it, so add new files to this list. The files share one global scope, so a duplicate global name is 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. The output has no module wrapper, so Apps Script can find logTypedRequestSummary. Do not change module to CommonJS or add export {} as an editor workaround. Either change turns the files into modules and hides their top-level functions.
types: ["google-apps-script"] loads the Apps Script declarations instead of every visible @types package. The types option controls which global declarations the checker loads. It does not change what exists at runtime, where using GoogleAppsScript as a runtime value throws ReferenceError: GoogleAppsScript is not defined. lib: ["ES2020"] supplies standard JavaScript declarations without the browser DOM. These settings help catch accidental use of a Node or browser global in server code. They do not prove that every ES2020 API exists in Apps Script.
target: "ES2020" selects the emitted JavaScript syntax level. V8 supports the JavaScript used here: function declarations, const, let, template literals, arrays, objects, and Set. The example needs no runtime helper or polyfill.
strict checks the typed program. noEmitOnError stops the compiler from writing output when it reports errors, but does not by itself delete an older successful build. The build script removes previous output before it compiles, so a failed build cannot leave an older dist behind.
Validate cell values before typing them
The sample data in requests.csv has the exact headers ID, Status, and Points. Each ID must match REQ- followed by three digits and must be unique. Status must be Open, Hold, or Done. Points must be an integer from 0 through 100. The program accepts at most 100 data rows. A blank row between records is an error, and the program does not skip it.
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 type. A value of that type 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 it emits JavaScript.
Google's Range.getValues() documentation says cells can contain numbers, strings, booleans, or dates, and that 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 only tells the checker to trust you and rejects none 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 pass, TypeScript can narrow the values enough to build 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 };
}
summarizeTypedRequests_ works in memory and returns one object. If a later row is invalid or repeats an ID, the function throws before returning, so the program never logs a partial total. The caller logs only after every row 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.
Before reading cells, the entrypoint checks that the script is bound to a spreadsheet and that a Requests sheet exists with three columns and at most 100 data rows. It reads at most A1:C101 in one getValues() call and passes the values to summarizeTypedRequests_, which checks each row with readTypedRequest_. The service declarations may expose a less restrictive type. The code still annotates the values as unknown[][], 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 underscore marks them as helpers by convention, though it does not stop anyone from calling them. Do not select them for the exercise, because 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 allows reading and writing the current spreadsheet. The program is read-only because it makes no write calls. 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 resolves paths from its own project directory, whatever the current working directory is. It refuses to use dist if that path is a file or symlink. Otherwise, it removes previous output and runs the installed compiler through the current Node executable. Node's spawnSync waits for the compiler to exit before the script continues. There is no shell-based compiler lookup and no automatic package download.
After a successful compile, the script renames dist/Code.js to dist/Code.gs and copies the manifest input. TypeScript has already compiled the file, so 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 an interruption as a failure. Clean and rebuild before you consider an upload.
Run:
npm run build A successful build reports that it created the two files and performed no upload or Apps Script execution. Open dist in your editor. Its complete 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 successful build does not authorize the script or show that it runs correctly in Apps Script. After upload, editor line numbers refer to the emitted Code.gs. Use them to find the matching TypeScript function, then repair src and rebuild. A repair made in the generated output is lost on the next build.
Keep uploads, source history, and credentials separate
{
"scriptId": "",
"rootDir": "dist",
"scriptExtensions": [".gs"]
}
**/*
!Code.gs
!appsscript.json
rootDir is dist. The clasp file-selection implementation applies ignore patterns relative to that upload root, so 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 as separate steps. No package script uploads on its own.
# 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 keep the authored .ts files, the Node build script, the manifest input, requests.csv, the clasp.example.json template (with no Script ID), the package files, the reviewed lockfile, and the ignore rules. It should not keep the generated dist, node_modules, the live .clasp.json, snapshots, or credentials. Follow the explicit-path staging and review procedure in Local Apps Script development with clasp, and do not publish this example repository as part of the exercise. .gitignore does not scan files for secrets, and it does not untrack files Git already tracks.
Point clasp at a test script 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 the contents of
requests.csvintoA1, choose Data > Split text to columns with the separator Comma, and verifyA1:C4. The Points cells must be the numbers3,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, copy its Script ID. The Script ID differs from the spreadsheet file ID and from any deployment ID.If necessary, enable Google Apps Script API in Apps Script user settings (
script.google.com/home/usersettings). 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 chapter, inspect that identity before deciding whether another login is needed. The book-test name only labels the stored credential, and it has the same access as any login for that account. Keep its token store, normally ~/.clasprc.json, out of source control, screenshots, and reports.
In .clasp.json, fill the empty scriptId string with the test Script ID. Leave rootDir as dist and scriptExtensions as [".gs"], and leave the template unchanged. Compare the saved ID with Project Settings, and reopen the script from the practice spreadsheet to confirm which spreadsheet it is bound to. Check both the signed-in account and the Script ID. One being correct does not confirm the other.
Inspect remote head before replacing it
Remote head is the content currently saved in the Apps Script project. Before the first push, inspect a fresh snapshot of it without pulling over your TypeScript inputs or build output. Create a new snapshot directory. If the name already exists, choose a different unused name so you do not 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 every downloaded file. A newly created bound project should contain only its initial source and manifest. If you find unexpected code, check with whoever wrote it before you replace it. A snapshot does not lock remote editing. Agree on a quiet upload window, and do not overwrite unreviewed changes made after the snapshot. Pulling emitted JavaScript cannot rebuild 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. Then 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 that runs logTypedRequestSummary in the bound editor authorizes the current-spreadsheet runtime scope separately.
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 requested permissions before authorizing. In Execution log, expect this summary object after 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, and the held request is counted without contributing open points. A second run with unchanged cells produces the same summary and changes no cells. A sheet with the three headers and no data rows produces 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;. Before uploading, predict openPoints. It stays 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, the selected target, the upload result, and which project ran. Do not edit the spreadsheet to fit the expectation.
Restore the original expression in src, rebuild, inspect, and upload the rebuilt baseline. Restoring the source locally does not change remote head until you push. Run the entrypoint and expect 8.
Make a local type error
With the expression 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, because a string initializer cannot satisfy number. Look in the project directory. The handled failure leaves no dist output that you could mistake for a fresh build. Do not push after the failure.
Restore the numeric 0 and build again. The build recreates the two upload files. The bad source was never built or uploaded, so the remote baseline still runs unchanged. The TypeScript checker reported this error on your computer, and it involved no Google authorization and no cell values.
Make a runtime input error
Keep the baseline source and sample data. 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 and confirm the prediction. Cell data is a separate input from uploaded code. Restore B3 to Hold.
Now change C3 from the number 2 to the text two. The local TypeScript project has not changed and still compiles. Running the uploaded function fails with:
Row 3: Points must be an integer from 0 through 100. That execution logs no summary. In Executions, note how this row-validation failure differs from a permission or missing-sheet error. Restore C3 to the number 2 (not text with a leading apostrophe) and run again. Expect the full baseline summary. TypeScript erased the number annotation during compilation, so the check in readTypedRequest_ is what 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 needs no 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 a review of its runtime dependencies. It may still be unusable if a dependency relies on the Node filesystem, the browser DOM, timers, or other APIs Apps Script lacks.
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 you change the build.
Remove only generated output
Restore the baseline source and the B3 and C3 values before finishing. A local Git restore, npm run clean, or closing the editor does not change remote head. Only a push does.
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, and refuses to remove a file or symlink at that path. It does not delete the source, the lockfile, credentials, the spreadsheet, or the remote script. Rebuilding from the retained inputs recreates the local upload output.
Keep the source and reviewed lockfile if you will continue. Inspect any snapshots or dependency directory before you remove them. If you no longer need the named book-test credential, identify it before using npm run clasp -- logout, and do not delete a shared home credential store. Revoking the grant on Google's side and disabling account-level API access are separate choices that can affect other tools.
Delete the disposable practice spreadsheet when you no longer need it.
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