Introduction
Google Apps Script is a powerful platform which makes it easy to automate and connect several Google services like Gmail, Google Drive, Google Forms, etc. with JavaScript. Triggers can execute scripts on a time-based schedule or events like form submissions. Consult the documentation for more information.
In this blog post, I will explain how to set up a Google Apps Script project with TypeScript support, multiple files, and bundling in your editor of choice.
Issues with Google's editor
The usual way of coding a Google script is via the editor on script.google.com. Here, click on "New Project" and start coding in Google's editor. This editor is easy to use and offers many of the features we want, but it has some drawbacks as well.
- There is no TypeScript support, which is kind of bad.
- You can write types with JSDocs and get autocompletion from it. Also, Google's classes like
DocumentApp
, etc. are properly documented and typed with JSDocs. However, there is no way to tell if there is a type error, the JSDocs don't have proper syntax highlighting, and not every type construction is possible in JSDocs. - To modularize the code, several files are possible. When you define the constant
const answer = 42
in a fileconfig.gs
, you can just use it another filemain.gs
. Google itself writes here that "All of the server files are loaded into the same global namespace". The issue, however, is that there are no explicit import/export statements in the code you see. In fact, you are not allowed to use import/export in a Google script. - This makes the code also more error-prone, since your editor will not tell you that something like
console.log(answr)
is wrong. It will error only during runtime. - There is no version control with Git.
- There is no automatic formatting of the code. For example, when you are in team semicolon, you might forget to add some semicolons in the code, and the editor will not tell you, let alone just add them for you. You also need to fix the indentation manually, which sucks.
- There is no support for dark mode. For Chrome, you may use the extension AppsScript Color. But this has some limitations, for example the menu bar is still light.
Limitations of clasp
Some of these issues can be solved with clasp. This is a tool that lets you develop a Google script locally within your preferred editor (VS Code, Vim, Webstorm, ...) and then sync it with the script hosted on Google's platform. It is very easy to use, more on that later. See also the documentation.
Some of the issues mentioned above will perhaps be fixed in future iterations on Google's editor. But we don't have to wait for that to happen and may work directly in our editor of choice.
But even with clasp you will still run into linting errors when using multiple files. The variables created in other files are not known. You cannot use import/export either. Clasp supports TypeScript and converts it to JavaScript for you, but its features are limited, in particular, because currently only an old version of TypeScript is supported. It is also awkward that this conversion is only done when the code is pushed to Google.
So what to do instead? My suggestion is to set up a local TypeScript project as you like, then bundle and transpile the files together with a bundler like esbuild
, and then push just the single output file to Google. This way, all the issues mentioned above are gone. The developer experience is superb.
In the next sections, I will explain step by step how this setup works. I will use the package manager pnpm
, with npm
and yarn
the commands are similar.
First steps with clasp
Obviously, you need a Google account first. Open a terminal and install clasp
globally with
pnpm add -g clasp
To authenticate yourself, run
clasp login
and follow the steps in the browser. To make clasp work, you also need to activate the Google Apps Script API on https://script.google.com/home/usersettings.
Create a new folder for your project and navigate to that project in your terminal.
mkdir clasp-typescript-sample
cd clasp-typescript-sample
Create a new standalone Google script with clasp.
clasp create --type standalone --title "choose some nice title"
Clasp will tell you the URL of the created Google script. You can either open it manually or run clasp open
. But currently, it's empty. So let us create the first file.
Open your preferred editor and create main.js
in the root of the project.
// main.js
console.log("hi mom!");
Also notice that the folder already has two files created by clasp
, namely .clasp.json
which has the ID of the script, and the so-called manifest file appscript.json
. Don't worry about these for now.
Now let us push this code to Google with the following command.
clasp push
When you open your Google script (or refresh the page if you already had it open), it will show the file main.gs
. Notice the extension for Google scripts is .gs
. But locally you can use the extension .js
(or .ts
later).
Sometimes you may also want to edit your script directly in Google's editor in the browser. In this case, you can pull this to your local editor with clasp pull
. But with the setup of this blog post, I would not recommend doing that. The local folder should be the single source of truth.
You may also now create a Git repository (and commit the changes we make later).
git init
git add .
git commit -m "setup of clasp"
Installing the dependencies
By now it is already clear that this is a project requiring clasp to work. We also need TypeScript and esbuild later. So let us install them right away as dev dependencies.
pnpm init
pnpm add -D clasp typescript esbuild
This means that the global installation of clasp before was redundant. I included this to get started more easily. Make sure to add node_modules
to the .gitignore
file.
In package.json
, let us specify type=module
and write a script for our push command.
{
"type": "module",
"scripts": {
"push": "clasp push"
}
}
Then pnpm push
will push our code to Google.
TypeScript setup
Rename the file main.js
to main.ts
and move it to a new folder src
.
// src/main.ts
console.log("hi mom!");
Generate a TypeScript configuration file tsconfig.json
in the root of the project and adjust it to your needs.
{
"compilerOptions": {
"target": "ESNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"moduleResolution": "node",
"resolveJsonModule": true
},
"include": ["src/**/*.ts"]
}
We will not use TypeScript to generate JavaScript output files, this will be done in the next step with esbuild. We only use TypeScript to get autocompletion and to ensure type safety and thus avoid runtime errors.
Bundling with esbuild
We can now also use several files and bundle them together. For illustration, create a new file that has the answer and exports it.
// src/answer.ts
export const answer = 42;
Import it in the main file and do something with it.
// src/main.ts
import { answer } from "./answer";
console.log("The answer is", answer);
We now create a build script that uses esbuild to bundle the files together into one output file, dist/code.js
. We also run a type check before.
{
"scripts": {
"push": "clasp push",
"build": "tsc --noEmit && esbuild src/main.ts --bundle --platform=node --outfile=dist/code.js"
}
}
For the transpilation, esbuild uses your tsconfig.json
.
After running pnpm build
, the output file will look like this.
// dist/code.js
"use strict";
// src/answer.ts
var answer = 42;
// src/main.ts
console.log("The answer is", answer);
Unfortunately, I did not find a way to configure esbuild to avoid having the outdated var
keyword in the output. But it does not matter that much if we keep the TypeScript source files as our single source of truth.
Add the output file dist/code.js
to the .gitignore
file.
You may also want to create a dev script that creates the output file as soon as some source file has changed.
{
"scripts": {
"dev": "tsc --noEmit && esbuild src/main.ts --bundle --platform=node --watch --outfile=dist/code.js"
}
}
But since we are not running the code here in any way, this might not be necessary, except for debugging purposes as for the bundling process itself.
For the build script, you should not use the --minify
option by esbuild. The reason is that you want to access the functions in Google's editor by their original names to set up triggers later. (And to my surprise the esbuild option --keep-names
does not do that.) However, you may use --minify-whitespace
and/or --minify-syntax
.
{
"scripts": {
"push": "clasp push",
"build": "tsc --noEmit && esbuild src/main.ts --bundle --minify-whitespace --minify-syntax --platform=node --outfile=dist/code.js"
}
}
Applying both yields the much shorter output
"use strict";console.log("The answer is",42);
Minification of your code will probably not be necessary, but once pushed, it will make quite clear that the code in Google's editor should not be touched.
Adjust clasp
Now since the actual code for the Google script has moved to the dist
folder, we need to
- move the
appscript.json
to thedist
folder - Keep the
.clasp.json
in the root, but change therootDir
property inside of it to./dist
.
{ "scriptId": "123456789", "rootDir": "./dist" }
You could also move the .clasp.json
file to the dist
folder, but this would also require a change of the push
command like so: cd dist && clasp push && cd ..
. I do not recommend doing that and instead suggest keeping all the configuration files in the root of the project.
Try if pnpm push
still works. It should now only upload the code.js
file to Google. All the TypeScript stuff with multiple files is just for your local development pleasure.
Formatting
You can set up the formatting in this project to your needs and let the editor do all the manual work for you. For example, if you are using Prettier, add a configuration file .prettierrc
in the root.
{
"useTabs": true,
"printWidth": 120,
"semi": true,
"trailingComma": "all",
"singleQuote": false
}
To not (automatically) format the output file, add dist/code.js
in the file .prettierignore
.
This alone is an argument for using clasp, even when you are mainly developing in Google's editor and do not follow the setup here. From time to time, do clasp pull
, format locally, do clasp push
, and the formatting is done.
Google services
When the Google script is accessing Google's services via classes such as SpreadsheetApp
, you want to add the corresponding types so that you get autocompletion and also no type errors in your local editor.
pnpm add -D @types/google-apps-script
For example, if you are dealing with a script that is bound to a spreadsheet, you can now safely access that spreadsheet with
const sheet = SpreadsheetApp.getActiveSpreadsheet();
When writing sheet.
you will get a very convenient list of all the available methods on the sheet. Notice that this is also true for Google's editor in the browser, and it is even better there since JSDocs provide comprehensive documentation. In my experience, these JSDocs often make it unnecessary to consult the documentation pages by Google. Unfortunately, they are not present in our local editor.
Triggers
There is still an issue with triggers that we need to address.
Say you have implemented a function that syncs events from a Google calendar with a Google spreadsheet.
// src/main.ts
function sync_calendar_events() {
// some logic
}
As soon as this function is implemented in the Google script, you can add a trigger for this function that executes it, say, every hour.
Try to add the function above to main.ts
, run pnpm build
, and have a look at dist/code.js
. The function is not there. The reason is that the function is not used, and esbuild automatically gets rid of unused code, also when you don't minify.
The function will be there when you add
// src/main.ts
sync_calendar_events();
to the code. However, this is not what we want. Whenever any function in the script is run via a trigger, the rest of the code is run as well. This means that the function sync_calendar_events
will always be executed, even if you don't want that. In particular, in the trigger for sync_calendar_events
itself, the function will be run twice.
I tried to find a clean solution to this problem and convince esbuild to include unexecuted functions, but I did not succeed. There is a workaround, though.
The trick is to somehow use the function without executing it. For example:
// src/main.ts
if (1 < 0) console.log(typeof sync_calendar_events);
Alternative:
// src/main.ts
const isOK = typeof sync_calendar_events === "function";
if (!isOK) console.warn("function is not defined!");
You are probably ...triggered by this piece of code. And I am too. It is stupid. The if-condition is always false, so the code in the if-block is not run. But at least, it does the job.
When you now run pnpm build
, the function (and the stupid code) will be in the output. In particular, you will be able to add a trigger for the function.
The problem can also not be solved by executing the function in some other file, since remember that in the end there will be just one output file, either explicitly via our build process, or implicitly via Google's internal bundling.
Also, the problem remains when implementing triggers by code, since here functions are just referenced by their names, which are strings.
ScriptApp.newTrigger("sync_calendar_events")
.timeBased()
.everyHours(1)
.create();
In other words, even with this piece of code neither your editor nor esbuild will know that the function is being used.
If you really want to get rid of the stupid code, a post-build script can do this for you automatically. That is, write some cleanup logic in cleanup.js
for dist/code.js
, and then execute it like so:
{
"scripts": {
"build": "tsc --noEmit && esbuild src/main.ts --bundle --platform=node --outfile=dist/code.js && pnpm cleanup",
"cleanup": "node cleanup.js"
}
}
If you know better approaches, please let me know.
What about web apps?
Google scripts can also be deployed as web applications. These are usually written in Vanilla JavaScript. It seems plausible that the setup here can be extended in such a way that also web frameworks such as my beloved Svelte can be used for Google web apps. A quick search brought up two repositories (gas-svelte-app, Google-Apps-Script-Svelte-Starter) doing exactly this. Maybe this is the topic for another blog post then.