Google Apps ScriptsでTypescriptが超簡単に使えるようになった!

morishitaです。
Cloud Functions と並ぶ(?)Google のサーバレスな JavaScript 実行環境といえば Google Apps Scripts(GAS)です。 GAS ってあの Excel で言う VB スクリプト環境のようなものでしょう? と思ったあなた!
このエントリでその認識が変わると思います。
以前は使いやすいとは言い難かったGASですが、最近は使いやすくなってきました。 といっても、GAS 自体がアップデートされたのではなく周辺ツールが整備が進み開発・運用しやすい状況が整ってきたからです。
そして、なんと最近Typescript でとても実装しやすくなったので、それをご紹介したいと思います。

google/clasp

以前の GAS は Web エディタ上でしか実装できず、コードを VCS で管理することもままならない状況でしたが、Google からgoogle/claspがリリースされ、状況が改善されました。
これは GAS を管理するための CLI ツールで、Google Drive 上の GAS のコードをローカルに pull したり、逆に Google Drive 上の GAS のプロジェクトに push したりできます。
ということは、Git で管理しながらローカルの使い慣れたエディタでコードを書いて、GAS に push して実行するという開発ができるのです1
google/clasp が、この平成最後の夏にリリースされた v1.5.0 でなんと Typescript をサポートしたのです。
これまでも Webpack や Babel を使ってトランスパイルして ES6 や Typescript で GAS の開発はできました。 しかし、どんどんバージョンアップする Webpack や Babel に追従しようとしてアップデートするとビルドできなくなるようなトラブルも起こりがちでした。
でも、その苦労から解放されたのです。
少々、前置きが長くなりましたが、実際に使ってみましょう。

@google/clasp のインストールとローカル環境の初期化

google/clasp は Node.js のモジュールです。Node.js 4.7.4 以上が必要なので、用意してください。
Node.jsの準備ができたら、次のコマンドで、ローカル環境を作ります2
$ mkdir clasp-ts-sample
$ cd clasp-ts-sample
$ npm init -y
$ npm install @google/clasp tslint -D
$ npm install @types/google-apps-script -S
$ tslint --init # tslint は必須ではありませんが、大人のたしなみとして導入しましょう。
Typescript は明示的にインストールしなくても@google/claspが依存しているのでインストールされます。2018/09/10時点では Typescript 2.9.2がインストールされます。
@types/google-apps-script も導入することにより VSCode 等ではコード補完されるようになります。
SpreadsheetAppなど、GAS 固有のクラス群も定義されています
素晴らしい!!

GAS プロジェクトの作成

(2018/10/03 clasp 1.6.0を反映した記述に変更しました)
次のコマンドで、GAS プロジェクトのファイルを Google Drive に作成します。その後、生成されたコードをローカルに pull します3
clasp 1.6.0 で、createコマンドに --rootDirオプションが追加されました。 このオプションを使うと、ソースを置くディレクトリを指定できます。 --rootDirオプションを使ってソースディレクトリを指定しながらプロジェクトを作成します。
$ clasp create clasp-ts-sample --rootDir ./src
$ clasp pull
ここまででできたファイル構成は次の通りです。
clasp-ts-sample/
├── .clasp.json
├── node_modules/
├── package-lock.json
├── package.json
├── src/
│   ├── Code.ts
│   └── appsscript.json
└── tslint.json
--rootDirオプションを使わなければ、srcディレクトリ以下に作成されたファイルはプロジェクトのルートディレクトリに出力されます。 その場合、 clasp pushを実行すると、node_modules以下のすべての JS を読み込もうとして失敗します。
--rootDirオプションでソースディレクトリを指定してやると、そのディレクトリ以下のファイルだけを見るようになり、うまくclasp pushが動きます。なので、指定することをおすすめします。

--rootDirオプションを使わずにプロジェクトを作ってしまったら

(clasp 1.5.x までは以下の手順を自分でやる必要がありました)
次の様に.clasp.jsonrootDirを追加します。
{
  "scriptId": "******-***************************************************",
  "rootDir": "./src"
}
そして、src ディレクトリを作って、clasp pushの対象となるファイルを移動します。
$ mkdir src
$ mv appsscript.json src/
$ mv Code.js src/Code.ts
これで--rootDirオプションを使った場合と同じ状態になります。

Typescript のコードを PUSH してみる

clasp のリポジトリにあるサンプルをコピーして試してみます。
それが次のCode.ts です。alert を使っていた部分は、GAS では動かないので修正しています。
// 型定義
const isDone: boolean = false;
const height: number = 6;
const bob: string = "bob";
const list1: number[] = [1, 2, 3];
const list2: number[] = [1, 2, 3];

enum Color {
  Red,
  Green,
  Blue
}

const c: Color = Color.Green;
let notSure: any = 4;
notSure = "maybe a string instead";
notSure = false; // okay, definitely a boolean

function showMessage(data: string): void {
  // Void
  Logger.log(data);
}
showMessage("hello");

// クラス
class Hamburger {
  constructor() {
    // コンストラクタ
  }
  public listToppings() {
    // メソッド
  }
}

// テンプレート文字列
const name = "Sam";
const age = 42;
console.log(`hello my name is ${name}, and I am ${age} years old`);

// Rest arguments
const add = (a: number, b: number) => a + b;
const args = [3, 5];
add(...args); // same as `add(args[0], args[1])`, or `add.apply(null, args)`

// スプレッド構文 (array)
const cde = ["c", "d", "e"];
const scale = ["a", "b", ...cde, "f", "g"]; // ['a', 'b', 'c', 'd', 'e', 'f', 'g']

// スプレッド構文 (map)
const mapABC = { a: 5, b: 6, c: 3 };
const mapABCD = { ...mapABC, d: 7 }; // { a: 5, b: 6, c: 3, d: 7 }

// 分割代入
const jane = { firstName: "Jane", lastName: "Doe" };
const john = { firstName: "John", lastName: "Doe", middleName: "Smith" };
function sayName({ firstName, lastName, middleName = "N/A" }) {
  console.log(`Hello ${firstName} ${middleName} ${lastName}`);
}
sayName(jane); // -> Hello Jane N/A Doe
sayName(john); // -> Helo John Smith Doe

// Export (The export keyword is ignored)
export const pi = 3.141592;

// Google Apps Script の独自サービスの利用
const doc = DocumentApp.create("Hello, world!");
doc
  .getBody()
  .appendParagraph("This document was created by Google Apps Script.");

// デコレータ(高階関数)
function Override(label: string) {
  return (target: any, key: string) => {
    Object.defineProperty(target, key, {
      configurable: false,
      get: () => label
    });
  };
}
class Test {
  @Override("test") // invokes Override, which returns the decorator
  public name: string = "pat";
}
const t = new Test();
console.log(t.name); // 'test'
どうでしょう、次のような Typescript ならではのものを含むモダンな実装を含んでいます。
  • 型アノテーション
  • クラス
  • テンプレート文字列
  • スプレッドオペレータ
  • 部分代入
そして、Google Docs を扱う DocumentAppを利用するコードも含んでいます。
では、Google Drive 上の GAS プロジェクトに push してみましょう。
次のコマンドだけで、自動的にトランスパイルして、GAS に push してくれます。
$ clasp push
tscなどを使って事前にトランスパイルする必要はありません
tsconfig.jsonすら用意不要です4
Javascript のコードを push するように Typescript のコードも push できます。
続いて GAS プロジェクトに push されたコードを見てみましょう。 clasp open コマンドを実行すると Google Drive 上の GAS プロジェクトがブラウザで開きます。
次の様にファイル Code.gs としてトランスパイルされています。
var exports = exports || {};
var module = module || { exports: exports };
var __assign =
  (this && this.__assign) ||
  Object.assign ||
  function(t) {
    for (var s, i = 1, n = arguments.length; i < n; i++) {
      s = arguments[i];
      for (var p in s)
        if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];
    }
    return t;
  };
var __decorate =
  (this && this.__decorate) ||
  function(decorators, target, key, desc) {
    var c = arguments.length,
      r =
        c < 3
          ? target
          : desc === null
            ? (desc = Object.getOwnPropertyDescriptor(target, key))
            : desc,
      d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function")
      r = Reflect.decorate(decorators, target, key, desc);
    else
      for (var i = decorators.length - 1; i >= 0; i--)
        if ((d = decorators[i]))
          r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
  };

// 型定義
var isDone = false;
var height = 6;
var bob = "bob";
var list1 = [1, 2, 3];
var list2 = [1, 2, 3];
var Color;
(function(Color) {
  Color[(Color["Red"] = 0)] = "Red";
  Color[(Color["Green"] = 1)] = "Green";
  Color[(Color["Blue"] = 2)] = "Blue";
})(Color || (Color = {}));
var c = Color.Green;
var notSure = 4;
notSure = "maybe a string instead";
notSure = false; // okay, definitely a boolean
function showMessage(data) {
  Logger.log(data);
}
showMessage("hello");
// Classes
var Hamburger = /** @class */ (function() {
  function Hamburger() {
    // コンストラクタ
  }
  Hamburger.prototype.listToppings = function() {
    // メソッド
  };
  return Hamburger;
})();
// テンプレート文字列
var name = "Sam";
var age = 42;
console.log("hello my name is " + name + ", and I am " + age + " years old");
// Rest arguments
var add = function(a, b) {
  return a + b;
};
var args = [3, 5];
add.apply(void 0, args); // same as `add(args[0], args[1])`, or `add.apply(null, args)`
// スプレッド構文 (array)
var cde = ["c", "d", "e"];
var scale = ["a", "b"].concat(cde, ["f", "g"]); // ['a', 'b', 'c', 'd', 'e', 'f', 'g']
// スプレッド構文  (map)
var mapABC = { a: 5, b: 6, c: 3 };
var mapABCD = __assign({}, mapABC, { d: 7 }); // { a: 5, b: 6, c: 3, d: 7 }
// 部分代入
var jane = { firstName: "Jane", lastName: "Doe" };
var john = { firstName: "John", lastName: "Doe", middleName: "Smith" };
function sayName(_a) {
  var firstName = _a.firstName,
    lastName = _a.lastName,
    _b = _a.middleName,
    middleName = _b === void 0 ? "N/A" : _b;
  console.log("Hello " + firstName + " " + middleName + " " + lastName);
}
sayName(jane); // -> Hello Jane N/A Doe
sayName(john); // -> Helo John Smith Doe
// Export (The export keyword is ignored)
exports.pi = 3.141592;
// Google Apps Script の独自サービスの利用
var doc = DocumentApp.create("Hello, world!");
doc
  .getBody()
  .appendParagraph("This document was created by Google Apps Script.");
// デコレータ(高階関数)
function Override(label) {
  return function(target, key) {
    Object.defineProperty(target, key, {
      configurable: false,
      get: function() {
        return label;
      }
    });
  };
}
var Test = /** @class */ (function() {
  function Test() {
    this.name = "pat";
  }
  __decorate(
    [
      Override("test") // invokes Override, which returns the decorator
    ],
    Test.prototype,
    "name"
  );
  return Test;
})();
var t = new Test();
console.log(t.name); // 'test'

動作確認

GAS の Web エディターでは 3 つの関数が実行対象として選択できると思います。 その中から試しにOverrideを実行してみます。 Override以外の関数は実行されませんが、関数外の部分は実行されます。 もちろんちゃんと動きます。
console.logの出力はStackdriver Loggingに次のように出力されます。
また、DocumentApp.createして、中に文字列を書き込んでいる部分がありますが、 その出力として次のようなGoogle Docのファイルが Google Driveの中に作成されます。
とても簡単です。
また、clasp pushにはwatchモードまであります。 次のコマンドを実行しておけば、コードの変更を検知すると自動的に再 push してくれます。
$ clasp push --watch
これで実装->実行->また実装 のサイクルが少し楽になりますね。

まとめ

どうでしょう、これまで GAS を使ってきた方には、今までのやり方がバカバカしくなるほど簡単に Typescript で実装できることがおわかりいただけたと思います。
もう Typescript で GAS を実装しない理由が見当らないでしょう?
GAS は Cloud Functions に比べると制約が多く、Google Drive 上のアプリケーションの拡張用と思われがちです。
しかし次のような特徴を備えており、ユースケース次第では大変便利に使えるサービスです。
  • Sheets や Docs、Slides といった Google Drive 上のアプリケーションにアクセスしやすい
  • Gmail、BigQuery や Analytics などの一部の Google のサービスを利用でき、しかも SDK よりも手軽に使えるものもある
  • Web アプリケーションも作れる
  • 定期実行可能
  • そして、無料5
特に、BigQuery や Analytics のデータを集計して、レポートを作成する作業を自動化するには最も便利な環境だと思います。 SheetsやSlidesのファイルとしてGoogle Drive上に出力するのが簡単ですし、Gmail経由でメールも出せますし、定期実行できますし。
また、去年次の 2 つが使えるようになり、ますます運用しやすくなりました。
  • Apps Script dashboard
    • GAS 専用の管理ダッシュボード。
    • Google Drive に散らかりがちな GAS プロジェクトを一元管理できます。
    • Sheets ファイルなどに含まれる container-bound な GAS プロジェクトも管理できます。
  • Stackdriver Logging
    • 汎用のロギングサービス。
    • console.log等の出力がログとして記録されます。
    • デバッグや実行状況の確認が格段にやりやすくなりました。
うまく使えば業務の効率化に大いに役立ってくれる GAS を Typescript でモダンに開発しましょう。

参考

最後に

アクトインディでは エンジニアを募集しています。
  1. このような開発スタイルを最初に実現し、エポックメイキングなツールだったnode-google-apps-scriptはすでにディスコンとなっています。
  2. claspnpm install -g @google/claspでグローバルにインストールしてもいいのですが、私は ndenv で複数バージョンの Node.js をインストールしており、プロジェクトごとに Node.js のバージョンが異なったりします。それで、グローバルなインストールは避けています。代わりに、./node_module/.binを PATH に追加してプロジェクトディレクトリにインストールしたコマンドを実行できるようにしています。
  3. これまで、claspを使ったことがなければ、ログインと、API の有効化が必要になります。参考:GAS の Google 謹製 CLI ツール clasp
  4. claspts2gasを利用してトランスパイルしています。コンパイルオプションはこちら =>compilerOptions
  5. GAS のスクリプトの実行自体は無料ですが、有料サービスの API 呼び出た場合、別途課金されます。