Closure LibraryからTypeScriptの型定義を生成する

こんにちは、フロントエンドチームエキスパートチームの穴井(@pirosikick)です。

弊社の製品である kintone は Closure Tools (Closure Library と Closure Compiler の総称) を使って開発していますが、TypeScript を使ったモダンなスタックへの移行を検討しています。

その移行の過程で Closure Tools 側のコードを TypeScript で型安全に再利用したいケースが発生し、その解決策として Clutz というツールを試しています。

今回は、この Clutz がどういったツールなのか、その使用方法と注意点などについて紹介します。

この記事は次の条件に当てはまる方には特におすすめできる内容になっています。

  • Closure Tools(Closure Compiler, Closure Library)を使って開発しているプロダクトがある
  • Closure Tools が辛くなってきたので、TypeScript を使ったモダンなスタックに移行したい
  • Closure Tools に興味はないが、TypeScript の型情報を自動生成する CLI の活用に興味がある

※Closure Tools を脱したい人向けの記事で、「Closure Tools、激推しです!」という内容ではないのでご注意ください。

Clutz とは何なのか?

Clutz - Closure to TypeScript Declarations (.d.ts) generator.

Clutz とは、Closure Library を使って記述されたソースコードから TypeScript の型定義ファイル(拡張子が.d.tsのファイル)を出力する CLI ツールです。

イメージしやすいようにサンプルコードをもとに説明していきます。

0. 下準備

サンプルコードを用いた説明に入る前に、Clutz をインストールします。

Clutz は npm パッケージなどの利用しやすい形で提供されていません。今回は Clutz を npm パッケージとして使えるように公開している@teppeis/clutzを使います。

# @teppeis/clutzをグローバルにインストール
$ npm i -g @teppeis/clutz

# 引数を渡さずに実行するとUsageが表示される
$ clutz
No files or externs were given
Usage: clutz [options...] arguments...
(オプションが表示されるが長いので割愛)

1. サンプルコードを記述

Closure Library を使って記述されたサンプルコードを用意します。サンプルコードのコメントに番号を打っていますので、それに沿って説明します。

// src/closure/hello.js

// ①ネームスペースを定義
goog.provide("app.hello");

// ②JSDoc形式で型を記述できる
/**
 * @param {string} name
 */
app.hello.sayHello = function (name) {
  console.log(`Hello, ${name}`);
};

// ③グローバル変数にsayHello関数を晒す
goog.exportSymbol("app.hello.sayHello", app.hello.sayHello);

goog.provide

goog.provide関数は、ネームスペースを定義するための関数です。他のファイルでgoog.require('app.hello')と記述すると、このファイルを参照することができます。

② Closure Compiler を使った静的型チェック

Closure Compiler を使って静的型チェックを行うことができます。型は JSDoc 形式で記述します。TypeScript にも JSDoc 形式で型定義を行う機能がありますが、型の解釈やサポートしている記述方法が少し異なります。違いが気になる場合は以下の Closure Compiler のドキュメントと、TypeScript のドキュメントを見比べてみてください。

goog.exportSymbol

sayHello関数を TypeScript 側から呼び出せるようにするために、goog.exportSymbol関数を使ってグローバル変数に定義します。第一引数はネームスペースで、この例ではapp.hello.sayHelloというグローバル変数を定義しています。

2. サンプルコードから型定義ファイルを出力

サンプルコードを用意したので、Clutz で TypeScript の型定義ファイルを出力してみましょう。

clutz --partialInput src/closure/hello.jsと実行すると、標準出力に型定義が出力されます。--partialInputは、不明な型をいい感じに無視するオプションで、このオプションを指定しない場合は「console の型が分からないんだが」と怒られてしまいます。

# 標準出力に型定義を出力
$ clutz --partialInput src/closure/hello.js
//!! generated by clutz.
// Generated from src/closure/hello.js
declare namespace ಠ_ಠ.clutz.app.hello {
  function sayHello (name : string ) : void ;
}
declare module 'goog:app.hello' {
  import hello = ಠ_ಠ.clutz.app.hello;
  export = hello;
}

標準出力ではなくファイルに出力したい場合は、-o ファイルパスで指定します。

# ファイルに型定義を出力
$ clutz -o @types/closure.d.ts --partialInput src/closure/hello.js

$ cat @types/closure.d.ts
//!! generated by clutz.
// Generated from src/closure/hello.js
declare namespace ಠ_ಠ.clutz.app.hello {
  function sayHello (name : string ) : void ;
}
declare module 'goog:app.hello' {
  import hello = ಠ_ಠ.clutz.app.hello;
  export = hello;
}

3. 出力された型定義について

出力された型定義を見ると、ಠ_ಠ.clutz.app.helloと顔文字のようなネームスペースが定義されています。おそらく、他のネームスペースと絶対被らないように変な名前にしていると思われるので、あまり気にしなくて大丈夫です。

また、goog:app.helloというモジュールが定義されており、その中でಠ_ಠ.clutz.app.helloが export されています。これにより、TypeScript のコードからは、以下のようにgoog:app.helloを import する形でapp.hello.sayHelloを参照できます。

// src/ts/index.ts TypeScriptのコード
import { sayHello } from "goog:app.hello";

// 型的にはエラーは出ないが、
// このままでは実行時にエラーになる
sayHello("pirosikick");

上記のコードは TypeScript による型チェックのエラーは発生しませんが、実際にはgoog:app.helloというパッケージは存在しないので、実行時または webpack 等でのビルド時に「goog:app.hello というモジュールが存在しないよ」というようなエラーになってしまいます。

出力した型定義で型エラーが発生している場合

サンプルコードがシンプルだったので発生しませんでしたが、出力した型定義にGlobalObject等の未定義の型が含まれており、型エラーが発生するケースがあります。その場合は、Clutz のリポジトリにあるclosure.lib.d.tsGlobalObject等の型が定義してありますので、そちらを TypeScript の型の評価に入る場所に置くと解決します。

4. 出力された型と実コードの紐付けを行う

実行時/ビルド時のエラーを避けるために、goog:app.hellohello.jsで定義したグローバル変数(app.hello)との紐付けを行います。webpack を利用している場合は、externalsオプションを使います。

以下のように記述すると、"goog:app.hello"というモジュールが import(commonjs の場合は require)された場合、グローバル変数のapp.helloを代わりに使ってくれます。

// webpack.config.js
module.exports = {
    …,

  externals: {
    // "goog:app.hello"は、グローバル変数のapp.helloを使う
      'goog:app.hello': 'app.hello'
  },

    …
};

あとは、Closure Tools 側の JS ファイル、TypeScript 側の JS ファイル(webpack でビルドしたファイル)の順で読み込めば、sayHello関数を TypeScript から呼び出せます。

<!-- Closure Tools側のJSファイル(Closure Compilerでビルド) -->
<script src="hello-build.js"></script>

<!-- TS側のJSファイル(webpackでビルド) -->
<script src="main.js"></script>

サンプルコードは以下のレポジトリに置いています。git clone して npm ci したあとに npm start を実行することで動作を確認できます(コンソールに"Hello, pirosikick"出力されるだけのシンプルなものです)。

https://github.com/pirosikick/clutz-example

Clutz を使う上での注意点

Clutz を使う上で気をつけるべき点は以下のとおりです。

  • Clutz 自体の開発が活発とは言えない
  • 圧縮後のことを気にする必要がある

Clutz 自体の開発が活発とは言えない

Clutz のリポジトリを見ると分かりますが、開発が活発に行われているとは言い難い状況です。Clutz にずっと頼るというよりは、移行期間の一時的な利用に留めるのがよいと思います。

圧縮後のことを気にする必要がある

Closure Compiler でコードの圧縮を実行している場合、圧縮後にクラスのプロパティやメソッドの名前が変わってしまうので注意しましょう。型定義の生成には圧縮前のコードを使うので、出力された型に存在するプロパティ・メソッドが実行時に存在せず、実行エラーになってしまいます。

対策としては、メソッドの場合はgoog.exportProperty関数を使って圧縮後も同じ名前でアクセスできるように設定できます。

// SomeClassのsomeMethodを
// 圧縮後も"someMethod"という名前でアクセスできるように設定
goog.exportProperty(
  SomeClass.prototype,
  "someMethod",
  SomeClass.prototype.someMethod
);

プロパティは上記の方法を使っても圧縮後の名前を固定できないので、getter/setter メソッドを用意し、そのメソッドに対してgoog.exportPropertyを使いましょう。

おわりに

Clutz というニッチなツールについて解説しました。kintone のコードベースは巨大でモダンスタックへの移行は一筋縄ではいきません。フロントエンドエキスパートチームでは、このようなツールを活用することでより効率よく、より安全に移行できないか日々探究しています。

今回のサンプルを応用し、型が保証された状態で安全に Closure Tools 側のコードを再利用できるのでは!と期待しています。

また、今回紹介した Clutz 以外に次のようなツールもあります。

  • Closure Library で記述されたコードを TypeScript のコードに変換するGents
  • 逆に TypeScript のコードを Closure のコードに変換するTsickle
  • Closure Library を TypeScript で利用可能にしたts-closure-library

これらについても探究していますので、なにか発見があったらまたブログを書きたいと思います。