Google Apps Script Local Development Tutorial

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.

If you pay attention to the documentation displayed for the function you’ll notice that something is missing: the documentation of the parameter.

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.

6 thoughts on “Google Apps Script Local Development Tutorial”

  1. 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?

  2. 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’,” .

So, what do you think?

This site uses Akismet to reduce spam. Learn how your comment data is processed.