🦺

GAS関数だってフロントエンドから型安全に呼び出ししたい!

に公開

概要

GAS芸人の皆さん、こんにちは!

HtmlServiceを使ったWebアプリ開発、手軽に公開できて沼りますよね🫠
最近は@google/claspvite-plugin-singlefileを活用して、ReactやVueをGASにホストすることにハマっています。

補足:HtmlServiceとは?
Google Apps Script(GAS)でHTMLやJavaScriptを使ったWebアプリを作成・公開できる仕組みです。
GAS初心者の方は、公式ドキュメントも参考にしてみてください。

そんな中でフロントエンドからサーバサイド(GAS)の関数を呼び出すために必ず利用するAPIである
google.script.run」ですが、こんなお悩みありませんか?

  • 「サーバーサイド(GAS)の関数名や引数、なんだっけ...?🤔」
  • 「TypeScriptで書いてるのに、google.script.runだけ型が効かなくて不安...」
  • 「関数名を変えたら、フロント側で実行時エラーが出てしまった...😭」

そうですね、(global.)googleは型定義がないため、せっかくTypeScriptで開発しているのに、
一番重要な「関数の型定義」部分がany型になってしまいます。

そんな悩みを解決するために、サーバーサイド(GAS)の型定義を自動で抽出し、
フロントエンドで型安全に関数呼び出しできるようにするCLIツール「@ciderjs/gasnuki」を開発しました!

この記事では、@ciderjs/gasnukiを使って、GAS開発の体験を向上させる方法をご紹介します

パッケージ名

賢明な読者諸君にはお分かりの通り、パッケージ名の「gasnuki」は「ガス抜き」と「GAS(から型定義を)抜き取る」をかけて付けました(オヤジギャグ)

開発のきっかけ・背景

GASをフロントエンドから呼び出す際、google.script.run.someFunction()のようなAPIが提供されていますが、
GASはJavaScriptベースの言語のため、型情報が一切提供されません

また、@types/google-apps-scriptパッケージはサーバー側(GAS本体)の型定義のみをカバーしており、

  • フロントエンドから呼び出す関数の型
  • google.scriptがグローバルに定義されること

など、実際の開発現場で必要となる型安全性や型補完を考慮していません。

このような背景から、GASとフロントエンド間で型情報を共有し、型安全な開発を実現したいという思いで
@ciderjs/gasnukiの開発をしてみました!

*最近、TanstackRouterに触れて「自動でルーティング型情報が抽出されるの、めっちゃええやんけ!」となったことも遠因です😊

使い方

インストール

npm install --save-dev @ciderjs/gasnuki

型定義の抽出

例えば、serverディレクトリ配下にGASのTypeScriptコードがある場合、以下のコマンドでtypesに型定義を抽出できます

npx gasnuki

利用可能なオプション

オプション 説明 デフォルト
-s, --srcDir <dir> ソースディレクトリ名(プロジェクトルートからの相対パス) "server"
-o, --outDir <dir> 出力ディレクトリ名(プロジェクトルートからの相対パス) "types"
-f, --outputFile <file> 出力ファイル名 "appsscript.ts"
-w, --watch 変更を監視して型定義を自動生成 (デフォルト: false)
-p, --project <project> プロジェクトのルートディレクトリパス プロジェクトルート
-v, --version バージョン番号を出力します -
-h, --help ヘルプを表示します -

フロントエンドでの型定義利用例

抽出された型定義ファイルにはグローバルにgoogleコンテキストを定義しています。
tsconfig.jsonでincludeしてあげれば、下記のようにgoogleについても型安全に利用できます。

フロントエンドでの呼び出しでも補完される!
google.script.run.
// エディタ補完が効き、`withSuccessHandler`などの組み込みメソッド群と、
// サーバー側で定義した関数群が表示される
tsconfig.jsonの設定例

型定義ファイル(例:types/appsscript.ts)がTypeScriptプロジェクトで認識されるよう、tsconfig.jsonincludetypeRootstypesディレクトリを追加してください。

{
  "compilerOptions": {
    // 諸設定群...
  },
  "include": ["src", "types"] // "types"ディレクトリを認識させる
}

ウォッチオプション(npx gasnuki -w)で裏で起動しておけば、
サーバサイドの変更にも常時対応し、フロントエンドとの型情報不一致もなくなることでしょう…!

また、抽出された型定義(例:types/appsscript.ts)をフロントエンドのTypeScriptプロジェクトでimportすることで、
GAS関数の型安全な呼び出しが可能になります

フロントエンドでの利用例
// types/appsscript.ts で定義された型を利用
import type { ServerScripts } from '../types/appsscript';

// google.script.run で型安全に呼び出し
const callGas = (...args: Parameters<ServerScripts['someFunction']>) => {
  google
    .script
    .run
    .withSuccessHandler(
      // withSuccessHandler内のコールバックまでは型推論されないため
      (result: ReturnType<ServerScripts['someFunction']>) => console.log(result)
    )
    .someFunction(...args);
};

Promise化しても型定義がほしい!

さて、型定義だけを抽出しても面白みがないので、Promise化させた際の型安全な使い方をご紹介します
Promise化は以下記事を参考にしています:

modules/appsscript-promise.ts
import type { ServerScripts } from "../types/appsscript";

type Promised<T> = Readonly<{
  [K in keyof T]: T[K] extends (...args: infer A) => infer R
  ? (...args: A) => Promise<R>
  : T[K];
}>;

export const PromisedServerScripts = new Proxy({} as Promised<ServerScripts>, {
  get: (_, method: keyof ServerScripts) => {
    type TargetScriptType = ServerScripts[typeof method];
    if (
      ['withSuccessHandler', 'withFailureHandler', 'withUserObject'].includes(
        String(method),
      )
    ) {
      throw new Error('Method not allowed');
    }
    return (...args: Parameters<TargetScriptType>) =>
      new Promise<ReturnType<TargetScriptType>>((resolve, reject) => {
        google.script.run
          .withSuccessHandler<ReturnType<TargetScriptType>>(resolve)
          .withFailureHandler(reject)
          // @ts-expect-error arguments has some types
          [method](...args);
      });
  },
  set: () => {
    throw new Error('Method not allowed');
  }
});

Promise化させた関数をProxyで返すようにしています。
その中で@ciderjs/gasnukiで生成されたサーバサイドの型定義も読み込むことで、型安全性を確保しています。

利用例
import { PromisedServerScripts } from '@/modules/appsscript-promise';
import type { Content } from '../types/appsscript'; // サーバサイドファイルで定義したタイプ情報もエクスポートしている

const callGas = async () => {
  try {
    const response = await ServerScripts.getContents('label'); // Promise化された関数も型情報とともに補完が効く
    return JSON.parse(response) as Content;
  } catch (e) {
    console.error(e);
  }
}

まとめ

GAS開発だって工夫すれば型安全性を高め、より快適な開発体験を実現できます
@ciderjs/gasnukiを使って、サーバーサイドとフロントエンドの間で型情報を共有し、
TypeScriptの恩恵を最大限に活かしましょう!

型安全開発で、より良いGASライフを!🚀

Discussion