Using TypeScript with Google Apps Script and Google Ads Scripting

I just recently started a project that runs as a Google Apps Script (GAS), and as I also just recently tried TypeScript and loved it, I wanted to see if I could use it and compile it to code that could execute as a regular Google Apps Script. When you think about it, TypeScript is an excellent fit for Google Apps Script, as the Apps Script platform has limited debugging capabilities and uses tons of complex SDK-specific interfaces; TypeScript’s static type checking and support for type declaration files make coding for Apps Script easier and less error-prone.

However, using TypeScript with GAS or Google Ads Scripting is not as easy as simply calling the TypeScript compiler/transpiler (TSC) to transpile your .ts to .js. True, Google Apps Script is an extension of Javascript and that should in theory mean that transpiled JS should work fine, but it has a few key differences that require building for it be treated differently, primarily how it treats function and variable scope, which you can read about at the bottom of this post.

Typescript Transpiling Options:

I’ve created a simple repo that shows each of these options with the same example code. You can find it here: https://github.com/joshuatz/typescript-to-gas-demos

Option A: Google’s Recommendation – Using clasp

Clasp, or Command Line Apps Script Project, is a command line tool that lets you develop Apps Script code locally, in Javascript or TypeScript, and push via terminal to the Apps Script cloud, in the process magically getting transpiled and formatted for GAS. To begin using Clasp, the best place to start is here, or here if you are specifically interested in using it for TypeScript.

The trade-off for ease of use is:

  • Trust: You have to give the Clasp tool scoped permission to touch your Apps Script files, which is handled by requesting and then storing on your harddrive an OAuth token. Of course, you can revoke this at any time, and you can check the source code for Clasp itself to see what it does with it.
  • Increased “black-box” complexity: Although Clasp is open source, the amount of moving parts it adds to an Apps Script project and how it abstracts certain things so you don’t have to worry about them, means that you might understand less about what is happening behind the scenes.
    • For example, if you are using TypeScript with Clasp, when you use the command clasp push, behind the scenes it actually calls a tool called “ts2gas“, which is a TypeScript to Google Apps Script transpiler, which in turn, uses the TypeScript Compiler API and a final (heavily modified) call to transpileModule, which is similar to running TSC. Then Clasp kicks back in and uploads the transpiled JS files to your Google Drive / Apps Script Project.
    • With your code going through multiple steps like this, can you reliably predict what the transpiled JS will look like after being converted from TypeScript? Are you aware of all the limits of each step of the process?
  • Lack of compatibility with other Google Script environments
    • I can find no evidence that Clasp can work with Google Ads Scripts (formerly AdWords scripting)

Note that neither Clasp nor ts2gas seem to import the Google Apps Script type definitions, so see my notes here on adding them.

I wanted to understand more about how TypeScript could be transpiled to GAS compatible GS code, which is the impetus behind this post and the further options I explore below.

If you are developing for a Google Data Studio community connector, you might want to use dscc-gen, which is a wrapper around Clasp that provides some convenience methods for connector specific work. I could not get it to work on Windows, which was also part of my research into how all these pieces fit together.

Option B: Use ts2gas directly

There is nothing preventing you from using ts2gas directly, instead of through Clasp. However you would need to add a little bit of wrapper code, since unlike TSC which can take files as inputs, ts2gas will only take a string, and also returns a string, not a file. You can find an example of this in the sample repo here, and I’ve also pasted the simple build script below:

const ts2gas = require('ts2gas');
const fse = require('fs-extra');

// paths
const inDir = './';
const outDir = '../output-gs/';

// Iterate over TS files in this folder
fse.readdir('./',(err,fileList)=>{
    fileList.forEach((fileName,index)=>{
        if (/\.ts$/i.test(fileName)){
            let tsCode = fse.readFileSync(inDir + fileName,'utf-8');
            // Send to ts2gas
            let gasCode = ts2gas(tsCode);
            // Write out transpiled GS
            fse.writeFileSync(outDir + fileName.replace('.ts','.gs'),gasCode);
        }
    });
});

Option C: Transpile without modules

The fastest and easiest way to quickly generate a GAS compatible export from TypeScript is to simply turn off modules, telling TSC that everything will be in a shared scope. This is a sample tsconfig.json that meets these requirements:

{
    "compilerOptions": {
        "target": "es3",
        "module": "none",
        "outDir": "./out",
        "strict": true
    }
}

These also (should) automatically change a few crucial things in your IDE (works best with Visual Studio Code). Most importantly, because it knows the output shares a scope, it lets you reference functions and global variables across files, without using imports/exports. 

Option D: Transpile to single file (simple and advanced)

The main benefit of transpiling to a single GS file is that, if you chose not to use Clasp due to privacy concerns, having a single file output makes it easier to copy and paste directly into the online editor. The simple version of this example simply uses the outFile option of TSC to force the output to a single file:

{
  "compilerOptions": {
    "target": "es3",
    "module": "none",
    "outFile": "../output-gs/compiled.gs",
    "strict": true
  }
}

The advanced version uses a custom build script to remove places where the source file uses “import” or “export”, so that you can use imports/exports in your source TS without it messing up the exported GS.

 


Using TypeScript definitions for Google Apps Script and Google Ads Scripting

First, you are going to want to actually install the TypeScript definition packages:

Although, like any node package, you can install types globally with the -g flag, VSCode will not automatically pull from the global folder. The only way I know of to have it recognize a globally installed type module is to use the absolute path – for example, this worked:

import 'C:\\laragon\\bin\\nodejs\\node-v10.14.2-win-x64\\node_modules\\@types\\google-apps-script';
But of course, that is terrible practice, since it requires other devs to have the same global install path. Even if you are just developing for yourself, you might as well set up a package.json and locally install the type packages.

You might be wondering how this is handled as far as transpiling goes. Well, Clasp/ts2gas will automatically comment out your “import” lines when it is transpiled to JS, and if you are using a different option, you could manually comment them out by hand before running TSC, or use an automated build script that does so (see my options above for details).

Apps Script – JS Caveats – Scope and Hoisting

First, and please excuse any slightly inaccurate terminology, but Apps Script works a little differently than JS that runs on websites, or NodeJS environments. One of the key differences is how function & variable hoisting works. Normally, functions are hoisted to the top, along with global variables, meaning that even if you define a function at line 200 of your file, you can call it at line 1, because the interpreter “hoists” the function declaration and parses it first. This lets you do this kind of thing in plain JS:

Apps Script does this too, but with an important distinction as to what “scope” means and order of evaluation. With normal JS, functions are hoisted to the top of their scope, when the code block they are in is evaluated. In practice, this means that although separate script tags can share the same global scope, since they are evaluated separately as the page gets parsed top to bottom, functions declared in a separate script tag can only be called in a different script tag if where they are called from is either a “lower” script tag, or waits to call the function until where it is declared is evaluated by the JS engine. Since examples are much easier to understand than my rambling, here is a very practical example in plain Javascript:

<!-- Script A -->
<script>
    // call alpha before it is defined.
    alpha();
    function alpha(){
        console.log('running alpha');
        // call beta before it is defined.
        beta();
    }
</script>

<!-- Script B -->
<script>
    function beta(){
        console.log('running beta');
    }
</script>

<!-- Final -->
<script>
    // Call alpha again
    alpha();
</script>

In the example above, the first time alpha() runs, the script tag that the beta() definition is in has not yet been evaluated, so we get a fatal error. Then that script tag hits, and beta() gets defined, and automatically hoisted to the top of the scope (becoming window.beta()), and finally, the final script tag hits, calls alpha() again, and this time, although alpha is in a different script tag, both alpha() and beta() are now hoisted and can be called and alpha() runs without the fatal error. This is why order matters when adding script tags (either inline or calling external js files), and most web devs are very familiar with accidentally loading dependencies in the wrong order and getting undefined errors.

How does Google Apps Script differ in function and variable hoisting versus normal JS? Well, it is hard to find documentation on this, but from experimenting and what little public information there is, the conclusion is that AppScript actually considers different script files to not only share the same scope, but also be evaluated at the same time so function & var declarations are hoisted to the top, across files and regardless of order. Using the same example as above, slightly modified for the Apps Script Environment:

File-A.gs

test();
function test(){
  // call alpha before it is defined - in this file
  alpha();
  // call final before it is defined - in separate file Final.gs
  final();
}
function alpha(){
  Logger.log('running alpha');
  // call beta before it is defined - in separate file File-B.gs
  beta();
}

File-B.gs

function beta(){
  Logger.log('running beta');
}

Final.gs

function final(){
  // Call beta again
  beta();
}

If this was a webpage, and those were JS files getting called via script tags, you would expect all sorts of errors when test() gets called. For example, you would expect to get the same “beta is not defined” error, since beta is in a separate script file and should not yet be hoisted. However, because this is GS, we get no errors, and a perfect output:

[19-06-08 19:25:14:102 PDT] running alpha
[19-06-08 19:25:14:103 PDT] running beta
[19-06-08 19:25:14:103 PDT] running beta

So what exactly is going on? My suspicion is that Google Apps Script basically acts like it is joining all your GS files, no matter how many you have, into one single GS file that it then evaluates. Pretty much the only official documentation I have been able to find that backs this up is here:

You are not limited to a single server Code.gs file. You can spread server code across multiple files for ease of development. All of the server files are loaded into the same global namespace, so use JavaScript classes when you want to provide safe encapsulation.

The other reason why this is a logical conclusion is because it is basically a necessity given the way that the Apps Script online editor works. Unlike the web, where you can reorder <script> tags, you cannot control the order of .GS files within your project and thus, if the rule about sharing scope was not true, you would literally have to guess as to what order your files would be evaluated in and subsequently their functions hoisted.

Leave a Reply

Your email address will not be published. Required fields are marked *