Updated on September 20th 2021
In a previous blog post I talked about the limitations of Apps Script and its cloud editor. Today I’ll focus on showing you how to create a modern local development environment that uses Typescript and consumes libraries from npm.
For this purpose we will be using clasp
, a command line tool developed by Google that will allow us to connect our local development environment with Google Drive. As an obvious first step we need to install this package globally from npm.
$ npm install -g @google/clasp
To be able to use this cli, we need to enable its usage on our Google profile by going to https://script.google.com/home/usersettings
Next, we need to login into our profile using clasp
$ clasp login
This will open the browser and ask you to grant special permissions for the cli on your account.
With authentication out of the way, let’s turn to attention to the creation of a new project. clasp
allows the creation of both standalone and bound scripts using the option create
. To specify that we want to create a script that’s bound to a particular spreadsheet, we will need to use the property --parentId
that expects the id of a Google Drive file, in this case, a spreadsheet. The id can be found in the url of the spreadsheet like in the example below:
https://docs.google.com/spreadsheets/d/{file-id}/edit
Now that we have the file id we can proceed to create our local project:
$ mkdir useless-calculation
$ cd useless-calculation
$ clasp create --parentId "file-id"
This command will create the following file structure:
.
├── .clasp.json
└── appsscript.json
Both are configuration files, one for clasp (.clasp.json
) and another for Apps Script (appscript.json
).
.clasp.json
{"scriptId":"file-id"}
This is the file id that we defined when we created the project
appscript.json
{
"timeZone": "America/New_York",
"dependencies": {
},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8"
}
The most important thing about this file is that we are instructing the platform to use V8 Javascript engine to run our code. Because of that, we are able to use modern Javascript syntax for our project.
We can finally create our custom function that will be bound to our spreadsheet. This file will live for now at the root of our project.
index.js
/**
* Performs a useless calculation
*
* @param {number} x Base value of the calculation
*
* @customFunction
*/
function USELESS_CALCULATION(x) {
return x * 10;
}
With this file our new folder structure is:
.
├── .clasp.json
├── appsscript.json
└── index.js
To push our files to Google Drive, we can use clasp
$ clasp push
>>>
└─ appsscript.json
└─ index.js
Pushed 2 files.
If we go back to our spreadsheet and open the script editor, we will find our code deployed there.
Our index.js
file was renamed to index.gs
and the file appscript.json
is no longer visible.
In the spreadsheet, our custom function will be available to be used along with its documentation.
Adding Typescript
If we want to use Typescript instead of Javascript, we can simply change the extension of the file and add the types we need.
index.ts
/**
* Performs a useless calculation
*
* @param {number} x Base value of the calculation
*
* @customFunction
*/
function USELESS_CALCULATION(x: number): number {
return x * 10;
}
We can try pushing again this code to Google Drive.
$ clasp push
>>>
└─ appsscript.json
└─ index.ts
Pushed 2 files.
If we look at the code that is deployed we can see that the file was automatically compiled from Typescript to Google Apps Script (GAS).
index.gs
// Compiled using ts2gas 3.6.2 (TypeScript 3.9.5)
/**
* Performs a useless calculation
*
* @param {number} x Base value of the calculation
*
* @customFunction
*/
function USELESS_CALCULATION(x) {
return x * 10;
}
Although we have proven that it’s not strictly required, at least not to deploy code to Drive, our IDE requires the presence of the tsconfig.json
file to be able to provide all the tooling enabled by Typescript. Because it’s a good practice to keep all dependencies local to the project, we are going to create a package.json
file to define Typescript as a dependency.
$ npm init -y
$ npm install typescript
$ npx tsc --init
After running this commands, we should have the following folder structure:
.
├── .clasp.json
├── appsscript.json
├── index.ts
├── node_modules
├── package-lock.json
├── package.json
└── tsconfig.json
We have a quite a number of configuration files. If we run clasp push
the tool will push all the files to Drive when not all of them are needed. To select which files to push when running the command, clasp
uses a special file called .claspignore
that works the same way as .gitignore
.claspignore
**/**
!index.ts
!appsscript.json
In this case we have decided to ignore all files except for index.ts
and appscript.json
. If we try again to push code to Drive we will see that only those two files will be pushed.
$ clasp push
>>>
└─ appsscript.json
└─ index.ts
Pushed 2 files.
ES6 Modules
As explained in a previous article, even though Apps Script is able to use V8 to run our code, it doesn’t support ES6 modules. To prove it, let’s create two functions with the same name in different files and push the code to Drive.
utils1.ts
export function multiply(base: number, multiplier: number): number {
return base * multiplier;
}
utils2.ts
export function multiply(base: number, multiplier: number): number {
return base * multiplier * 100;
}
We can then modify our main function to make use of one of this utility functions.
index.ts
import { multiply } from './utils1';
/**
* Performs a useless calculation
*
* @param {number} x Base value of the calculation
*
* @customFunction
*/
function USELESS_CALCULATION(x: number): number {
return multiply(x, 10);
}
Because we are using the multiply
function defined inside of utils1.ts
file, if we pass the number 5 to our main function, we should see as an output 50.
Before pushing the files to drive to verify our assumption, let’s move our source files into it’s own src
folder for better code organization.
.
├── .clasp.json
├── .claspignore
├── appsscript.json
├── package-lock.json
├── package.json
├── src
│ ├── index.ts
│ ├── utils1.ts
│ └── utils2.ts
└── tsconfig.json
Because we modified our folder structure, we need to also modify the .claspignore
file so all the files stored on the src
folder are not ignored when trying to synchronize our code with Drive.
.claspignore
**/**
!src/**
!appsscript.json
We are ready now to push our code into Drive.
$ clasp push
>>>
└─ appsscript.json
└─ src/index.ts
└─ src/utils1.ts
└─ src/utils2.ts
Pushed 4 files.
If we go to our spreadsheet and try again to use the main function we will find that, surprisingly, the wrong version of the multiply
function is used.
What’s happening? If we inspect the code that was deployed to our spreadsheet we can see that our module definition is completely ignored besides the extra code generated by the compiler.
src/index.gs
// Compiled using ts2gas 3.6.2 (TypeScript 3.9.5)
var exports = exports || {};
var module = module || { exports: exports };
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//import { multiply } from './utils1';
/**
* Performs a useless calculation
*
* @param {number} x Base value of the calculation
*
* @customFunction
*/
function USELESS_CALCULATION(x) {
return multiply(x, 10);
}
This extra code generated at the top is doing nothing for properly scoping multiply
to the right file. In this context the utility function needs to be defined globally to work. If we inspect the other two files, we can see a similar situation.
src/utils1.gs
// Compiled using ts2gas 3.6.2 (TypeScript 3.9.5)
var exports = exports || {};
var module = module || { exports: exports };
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.multiply = void 0;
function multiply(base, multiplier) {
return base * multiplier;
}
exports.multiply = multiply;
src/utils2.gs
// Compiled using ts2gas 3.6.2 (TypeScript 3.9.5)
var exports = exports || {};
var module = module || { exports: exports };
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.multiply = void 0;
function multiply(base, multiplier) {
return base * multiplier * 100;
}
exports.multiply = multiply;
Even if we tried to use the exports
object in our main function to access multiply
, notice how both files are using the same exports.multiply
property. This means that the function defined last (src/utils2.gs
) will take precedence disregarding how the import
was defined. This explains why our result was 5000 instead of just 50.
Bundling with Webpack
To overcome the lack of support of ES6 Modules we can use webpack to bundle our script into a single file that properly handles scopes. A basic webpack configuration files that handles Typescript compilation is shown below.
webpack.config.js
const path = require('path');
const config = {
entry: './src/index.ts',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.ts$/,
use: [
'ts-loader'
],
exclude: /node_modules/
}
]
},
resolve: {
extensions: ['.ts']
}
};
module.exports = config
For webpack to work with the required plugin we need to install the relevant packages from npm.
$ npm install webpack webpack-cli ts-loader
There’s an optional optimization we can make to Typescript when compiling our code. By default, Typescript will try to convert our files into ES5 compatible code but given that our code will run on top of V8, we can keep our modern syntax intact after compilation.
tsconfig.json
{
"compilerOptions": {
"target": "ESNEXT",
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true
}
}
We also removed the property module
from the configuration file as webpack and not Typescript will be in charge of resolving the modules when creating the bundle. Finally, we need to create a new script in our package.json
file to perform the build.
package.json
{
"name": "useless-calculation",
"private": true,
"scripts": {
"build": "webpack --mode none"
},
"devDependencies": {
"ts-loader": "^9.2.5",
"typescript": "^4.4.2",
"webpack": "^5.51.1",
"webpack-cli": "^4.8.0"
}
}
We are setting the mode to none
to disable all the plugins that are activated by default so we have better control of our output. We can now create our bundle for the first time by running:
$ npm run build
We should have now a new bundle.js
file in our dist
folder. For reference, at this point, this is the current folder structure
.
├── .clasp.json
├── .claspignore
├── appsscript.json
├── dist
│ └── bundle.js
├── package-lock.json
├── package.json
├── src
│ ├── index.ts
│ ├── utils1.ts
│ └── utils2.ts
├── tsconfig.json
└── webpack.config.js
Before pushing our resulting code to Drive for testing, we need to modify .claspignore
file so our new bundle.js
gets synchronized when using clasp.
.claspignore
**/**
!dist/**
!appsscript.json
We can finally push our code again and see the results.
$ clasp push
Sadly, if we open our spreadsheet we will see an error.
The problem is that in order to properly scope modules webpack wraps our entire script into an IIFE which prevents Drive from discovering our function. Fortunately for us, there’s a webpack plugin for GAS that takes care of this problem: gas-webpack-plugin
.
$ npm install gas-webpack-plugin
After installed we can activate the plugin in our webpack configuration file:
webpack.config.js
const path = require('path');
const GasPlugin = require('gas-webpack-plugin');
const config = {
entry: './src/index.ts',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.ts$/,
use: [
'ts-loader'
],
exclude: /node_modules/
}
]
},
resolve: {
extensions: ['.ts']
},
plugins: [
new GasPlugin()
]
};
module.exports = config;
Following the package documentation, for the plugin to work we need to attach our main function to the global
object so I’m going to create a new file called global.ts
where I’ll take care of attaching our custom function to the global object. This code organization is optional but it’s nice to separate regular code (index.ts
) from “special” code (global.ts
) that deals with Google Drive quirks.
index.ts
import { multiply } from './utils1';
export const uselessCalculation = (x: number): number => {
return multiply(x, 10);
}
global.ts
import { uselessCalculation } from './index';
/**
* Performs a useless calculation
*
* @param {number} x Base value of the calculation
*
* @customFunction
*/
(global as any).USELESS_CALCULATION = uselessCalculation;
Notice that now index.ts
is using regular camel case convention while global.ts
is using upper case as it’s normal in Apps Script custom functions.
If your IDE or webpack complains about not knowing what global
is, just install the NodeJS type definitions.
$ npm install @types/node
We can build our code again and push the changes to Drive.
$ npm run build
$ clasp push
If we check our spreadsheet again, we can see that our custom function is now returning the correct value.
Fixing the Documentation
Although we clearly documented the function parameter with JSDoc, the generated code for the global function that was created by the GasPlugin
doesn’t capture the parameter x as it can be seen in the code deployed to our spreadsheet.
bundle.gs
/**
* Performs a useless calculation
*
* @param {number} x Base value of the calculation
*
* @customFunction
*/
function USELESS_CALCULATION() {
}/******/ (() => { // webpackBootstrap
/******/ "use strict";
/******/ var __webpack_modules__ = ([
/* 0 */,
/* 1 */
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
...
Although the function works just fine when passed a number, USELESS_CALCULATION
doesn’t seem to have a parameter and that’s why the displayed documentation doesn’t show information about the parameter. A workaround is to explicitly define those parameters in the global object even at the cost of code duplication.
global.ts
import { uselessCalculation } from './index';
/**
* Performs a useless calculation
*
* @param {number} x Base value of the calculation
*
* @customFunction
*/
(global as any).USELESS_CALCULATION = (x: number) => uselessCalculation(x);
After building and deploying the project (npm run build
) we can see that the documentation works as expected.
Optimizations
Now that our code is working correctly we can make some adjustments to reduce the size of the bundle. The regular optimization mode (webpack --mode production
) is too aggressive for Apps Script and it will end up breaking the build again. Instead we can manually configure some options to safely minimize the code while preserving the name of the function (important for Drive to be able to discovery it) and the comments that act as documentation (jsdoc).
webpack.config.js
const path = require('path');
const GasPlugin = require('gas-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const config = {
entry: './src/index.ts',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.ts$/,
use: [
'ts-loader'
],
exclude: /node_modules/
}
]
},
resolve: {
extensions: ['.ts']
},
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
terserOptions: {
mangle: false,
output: {
comments: /@customFunction/i
}
}
}),
]
},
plugins: [
new GasPlugin()
]
};
module.exports = config;
We can build and push our code one more time and it should continue working as expected.
Summary
We learned that’s possible to overcome Apps Script limitations by moving away from its cloud development environment to a modern local setup that uses Typescript and Webpack.
The code for this tutorial is published here and I’ve also created a starter kit that includes Jest and uses yarn for package management here.
That was an excellent tutorial; you linearized the steps really well. Thanks.
Great Tutorial! Thanks!
You Sir… are the Real MVP,
Been searching for this for a while. Well written and easy to understand
Thanks for the great tutorial.
Have you ever saved the problem with the
The value returned from Apps Script has a type that cannot be used by the add-ons platform. Also make sure to call 'build' on any builder before returning it.
error?I don’t recall seeing that error before
Hi ! Thank you for this tutorial. I may have missed something, but on my side it didn’t work after adding the global.ts file. Solved by modifying webpack.config.js.
“entry: ‘./src/index.ts’,” to “entry: ‘./src/global.ts’,” .