📝

モジュール化とローカル型定義をがんばった話

2021/07/04に公開

こんにちは、@armorik83です。私は1年ちょっとAngularJSをベースに書き続けていますが、それらはTypeScriptで書いています。もう無しじゃやってられん。

今回はゆるい体験談です。

何を作ってる?

node.js + expressでサーバ周りを整え、フロントはAngularJS、完全なローカル環境で完結する業務システムを作っています。PaaSは現時点では無縁でやっているので、ちょっとニッチなテーマかも。

肥大化

統計処理やデータベースまわり、APIまわり(サーバ、AngularJSともに)、UIのリッチ化、まあ色々進めてきて1万行を超えました。

ここへきてテストの速度がえらく遅くなってきたのが最大の悩みで、ちょうどその時データベースに渡すオブジェクトの実装にボトルネックがありガラッと変えたかったこともあって、差し替えるためにモジュール化を進めました。

ローカルモジュール化

プロジェクト内の依存についてはpackage.jsonに全て書いていますが、npm 2.0から直接ファイルパスを記述するローカルモジュールが使えるようになったのでこれを採用。

$ npm iすると.npmignoreの指定に無い残りのファイルをコピーしてくれるので、あたかもnpmに登録されたモジュールのように扱えます。(余談ですが.npmignoreより『細かすぎて伝わらない package.json 小ネタ三選』にあるホワイトリストを採用したほうがよさそうです)

どの型定義を参照するか

全実装を本体プロジェクトに納めていた今までは、/// <reference path="">にベタっと参照を書いていたので問題が起きなかった。今回はビルドしたひとつのJSファイルしかない。

考えたアプローチはふたつ。

  1. 実装上使われないTypeScriptソースもnpm i時に一緒にコピーされるようにして、型参照のためだけにそれを使う。
  2. 型定義を書く。

1.はどう考えても冗長ですが一応試しました。残念なことに一つのローカルモジュールに対して複数のソースから依存するとき、分けてコンパイルしないとTS2300: Duplicate identifierエラーが大量に並ぶ結果に。

型定義を書くのが一番早そう。

OSSじゃなくても型定義を書く

よく考えると当たり前の話なんですが、TypeScriptを書き始めて間も無かった頃はまだ、*.d.tsはOSSなど既存JSライブラリに対して書く、自分の型は*.ts内のinterfaceで書くという使い分けるものだと勘違いしており、この発想に辿り着くまで無駄骨を折っていました。

普通に自作ライブラリに対しても*.d.tsを書いてしまえばいいのです。

ローカル型定義を書く

型定義ファイルの雛形作成には、

$ tsc -d mysource.ts

が簡単。ただ、吐き出すのがinterface宣言ではなくdeclare class宣言でprivateもまとめて出すので、それらは除去してinterfaceに改めています。

StaticとInstance

JavaScriptに型を定義しようとすると、

  • MyClass.prototype.*を持つオブジェクト(MyClassのインスタンス)
  • MyClassがstaticで持つメソッド群
  • 単なるオブジェクト・リテラル{}

の様々な概念に対して型定義の機会が発生します。記述のアプローチやスタイルはlib.d.tsDefinitelyTypedの有名どころを参照するのが手堅い。

既存型定義の例

例えばjQueryでは

  • グローバル変数$が持つメソッド群にはJQueryStatic
  • $('.foo')で得られるインスタンスにはJQuery

という命名がされています。また、AngularJSでは

  • グローバル変数angularが持つメソッド群にはng.IAngularStatic
  • directiveという要素を定義するオブジェクト・リテラルにはng.IDirective

などの命名がされています。

Iを付けるか否かや、staticに宣言されたメソッド群を*Staticとするのは文化的側面を感じますが、Microsoftの最新のガイドラインに従うのが最良のようです。

自作型定義の例

Entityというローカルモジュールに対する型定義ファイルの一例です。

entity.d.ts
export interface EntityConstructor {
  new(entity: EntityProperty): Entity;
}

export interface EntityProperty {
  entityType: string;
  name:       string;
  userId:     number;

  created?:  number;
  modified?: number;
  rating?:   number;
}

export interface Entity extends EntityProperty {
  /**
   * @deprecated
   */
  toObject(): EntityProperty;

  touchModified(): Entity;
  isArticle():     boolean;
}
  • new Entity({...})するときの引数の型を決めるためにEntityConstructorを宣言している
  • MongoDBでJSONを扱う都合上インスタンス・メソッドを持たないオブジェクトリテラル状態も存在するのでEntityPropertyとしてプロパティ群は別で宣言
  • EntityのインスタンスはEntityPropertyのプロパティ群とインスタンス・メソッドを併せ持つのでextendsする
  • toObject()という名残のメソッドが移行期間中残ったままになるのでWebStormのためにJSDoc@deprecatedを書く

といったところ。特にIDEの補完はTSソースを読みにいかずこの型定義から情報を得ようとするので、@deprecatedなどもここに書く必要があります。

型定義の置き場

どうしようか迷って、node_modules/に直接読みに行くなど試行錯誤したところ、tsdを使っていたので最終的にはtypings/に置くことにしました。

こんな構造のtimeというモジュールがあったとして、

[ローカルモジュールのルート]
├── Gruntfile.ts
├── README.md
├── index.js         <-ビルド結果
├── interface
│   └── time.d.ts
├── lib
│   └── time.ts
├── node_modules
├── package.json
├── test
│   └── time-spec.ts
├── tsd.json
└── typings
    └── *

使う側では、

[プロジェクト本体]
├── app
├── lib
├── node_modules
│   └── time                <- npm iでコピーされてくる
│       └── interface       <- ここを参照
│           └── time.d.ts
├── test
└── typings
    ├── angularjs
    ├── jquery
    ├── node
    └── time
        └── interface       <- ここに貼る
            └── time.d.ts

コピーすると保守がつらいので全てシンボリック・リンク。ただし「依存の依存」がある場合tscはシンボリック・リンクの位置を起点に辿るので、ディレクトリ構造を大元とtypings/で同じにしないとエラーとなるので注意。ここがちょっと面倒なので、もっといい方法がありそうと模索中。(何かあれば教えてください)


自作型定義を別で作っておけば、急にこのライブラリはOSSとして公開できるぞと踏み切るとき、すぐにDefinitelyTypedにPRできますね。

新しい型定義や、DefinitelyTyped内の既存の型定義の修正は@vvakameさんの記事『TypeScriptの型定義ファイルを共有しよう!』を読みながらが間違いないので、どんどん共有していったらいいでしょう!(私もやってみた)

以上、ローカルモジュールをTypeScriptで書くなら型定義も恐れずどんどん書こうぜって話でした。

Discussion