第6章「JavaScriptの資産と@types」で型定義ファイルの取得方法、使い方を学びました。しかし、世の中にあるJavaScriptライブラリのうち、型定義ファイルが書かれていないものはまだまだあります。特に、門外不出の社内ライブラリなどは誰も手をつけて(いるわけが)ない前人未到の地です。
しからば!自分で書くしかあるまいよ!ぶっちゃけた話、めんどくさいのですが、後々の安心・安全を得るための投資として割りきりましょう。
なお、自分で型定義ファイルを1行も書かずにTypeScriptをやると、どこかの時点で不便に感じるでしょう。最初のうちは無理かもしれませんが、まずは人が書いた型定義ファイルを読んで知識を蓄え、この世界に入ってきてくれると嬉しいです。
TypeScriptはJavaScriptに対して後付で型による制約を付け足した言語です。そのため、JavaやC#のような最初から型ありきの言語より少し考え方が複雑です。具体的にいえば型と実体(値)というものが分かれています。
すべてがTypeScriptで書かれたプログラムであれば、型と実体は基本的には一致しています。クラスの定義を書いたとき、JavaScriptプログラムとしてのクラスと、TypeScriptで使う型としてのクラスが一度に誕生します。これは大変素直かつ簡単な動作で、ひとつの記述から型と実体を作成しているためこの2つが乖離してしまうことはありません。
一方、JavaScriptでコードを書いてTypeScriptで型定義ファイルを作成して使う場合、実装と型が個別に定義されることになります。そのため、型と実体が分離してしまい、この2つの間に乖離が生じると(つまりバグると)コンパイルが通るのに実行時エラーが多発する、というありさまになるわけです。型定義ファイルを書いて"この変数は、あります!"と宣言したけれど、実際には存在せず実行時エラーになるというのは広く使われている型定義ファイルですらままある話です。これはもうどうしようもない話ですので、我慢するか自分で修正するしかありません。全世界がTypeScriptに制覇されることによりこの葛藤は解消される予定です。
型定義ファイルにも良し悪しがあります。その基準は至って簡単です。
正しいライブラリの使い方を導く、というのは裏を返せば間違った使い方ができないようにする、ということです。これには型と実体の定義に乖離が存在せず、コンパイルが通ったら実行時エラーが簡単には起こらないことも含まれます。
他のコードや型定義ファイルに意図せぬ干渉を引き起こさないこと、というのは意図せぬインタフェースの統合などが起こらないことを指します。このためには汎用的な名前を使うのを避け、可読性が高く理解しやすい型定義を作り、干渉した場合に容易に判断できるようにすることも含まれます。
IDE上で使いやすいことというのは、Visual Studio Codeなどでコードを書く上で入力補完の候補が不用意に出過ぎないようにして見通しのよい開発を助けることなどが含まれます。
これら3つを守ることが"良い品質であること"に繋がるというのは、TypeScript自体が型指定を行うことで間違ったコードを書きにくいようにするツールであると考えると納得がいくでしょう。
慣れないうちはどうしても"うまく書けないので仕方なく"悪い型定義を書いてしまうことがあります。DefinitelyTypedにpull requestを送ってくれる人にもそういう人は多くいます。
これから説明するベストプラクティスを踏まえて、より良い型定義ファイルを作成できるように鍛錬していきましょう。
型定義ファイルを書く上でのベストプラクティスを解説していきます。基本的には公式HandbookのWriting Declaration Files*1とDefinitelyTypedのbest practices*2にしたがっておけばよいです。本章では、そこに書かれていることや筆者の経験則などを説明していきます。
一番最初にコレを書くのもどうかと思うのですが、まずは"使える"ようにするのが一番大切です。
型定義ファイルの品質の良さにこだわるあまり、完成しない、使いたいライブラリが使えない、というのがもっともよくない状態です。型定義ファイルの良し悪しを判断する力は、TypeScript自体への理解度に大きく依存します。TypeScriptを書き始めたばかりに作ったものは上達するにつけ後々粗が見えてくるのは避けられません。つまり、避けられないことを気にしたってしかたないよな!まずは"使える"状態にすることを目指しましょう。
品質や"ライブラリ全体をカバーしている"かは気になるところではあります。しかし、まずは使いたいところが使えればいいのです。スゴいものになると、1万行を超える型定義ファイルがあります。また3000行程度のものはわりとごろごろしています…。しかし、そんなにも頑張って書いてると余裕で日が暮れてしまいます*3。
[*3] なお筆者はGitHubの作っているエディタ、Atomの型定義ファイルを3日かけて書いたことがあります。アレがジゴクだ
まずは、使いたいところが、使える!それでよいのです。ドラゴン・ゲンドーソー=先生のインストラクション・ワンを思い出しましょう。
百発のスリケンで倒せぬ相手だからといって、一発の力に頼ってはならぬ。一千発のスリケンを投げるのだ!
最初はうまくできなくても数をこなし学習を重ねれば、そのうち立派な型定義ファイルを書けるようになるでしょう。
はい。まずは気軽にスタートする方法から入っていきましょう。何も考えずにスタートするとanyが至るところに流入してくるので少しずつ定義を育てていき、立派な安全さを得ていきましょう。
まず一番簡単なのは--noImplicitAny
オプションが指定されていない場合に限り、型定義のないモジュールをimportするとエラーにならずにanyとして参照できるというものです。これは簡単に始められますが--noImplicitAny
を使わないというのがそもそも厳しいため、書き捨てのスクリプトを作るときくらいしか出番が無さそうです。
次に使える手法として、モジュールの型定義を簡略表記する方法とワイルドカードがあります。定義の例リスト7.1とそれを使う例リスト7.2を確認してみます。
リスト7.1: 定義の例 名前を書くだけ
declare module "jquery"; // こういう定義と同等 declare module "jquery-alt" { var _temp: any; export = _temp; } // ワイルドカードも使える declare module "json!*"; declare module "sample/*";
リスト7.2: 利用例 とりあえず使える
import * as $ from "jquery"; // $ はany $.notExists(); // これらもコンパイルが通る import * as json from "json!package.json"; import * as sampleFoo from "sample/foo"; import * as sampleFooBar from "sample/foo/bar"; export { $, json, sampleFoo, sampleFooBar }
簡単ですね!とりあえず、開発を始めることができます。
インタフェースは大変使いやすいパーツです。というのも、インタフェースには後から定義を拡張できるという特性があるからです(リスト7.3、リスト7.4)*4。
[*4] ちなみに、classの定義も後から拡張可能です https://github.com/Microsoft/TypeScript/issues/3332
リスト7.3: 定義を分割して書く
interface Foo { hello(): string; } // 同名のインタフェースを定義すると、合成される! interface Foo { bye(): string; }
リスト7.4: 定義が統合される!
/// <reference path="./declarationMerging.d.ts" /> // ↑ 昔はこのようにreference commentを使ってファイル間の依存関係を明示していましたが、 // 最近はtsconfig.jsonに依存関係を書くため見かけることはほぼなくなりました let foo: Foo = null as any; foo.hello(); foo.bye(); export { }
このとおり別々に定義したインタフェースがひとつに統合されています。これを利用することで、既存の型であろうとも拡張が可能になるのです。
例をひとつ見てみましょう。String#trimStart
は、文字列の先頭にある空白文字を取り除く機能です。本章執筆時点(2017年07月16日)では、この提案*5はTC39のプロポーザルでstage 2*6で、TypeScriptにはまだ入ってきていません。そのためStringインタフェースを拡張する形でコンパイルを通せるようにしてみましょう(リスト7.5)
リスト7.5: String#trimStartを生やす
interface String { trimStart(): string; } let str = " TypeScript "; // 文字列先頭の空白文字を削る console.log(str.trimStart());
あとは、実行時にString.prototype.trimStartを適当な実装で補ってやれば未サポートのブラウザでも利用可能になるでしょう。
この手法は、他人が作った型定義ファイルを拡張する場合にも活用できます。相乗りできるのであれば遠慮なく乗っかっていってしまいましょう。
幽霊namespace*7という考え方があります。
[*7] TypeScriptリファレンスでは非インスタンス化モジュールという名前で紹介しました。その後、DefinitelyTypedのbest practicesでghost moduleと表記された
namespaceを作ったとしても、即座に実体が生成されるとは限りません。namespaceが抱えるものがインタフェースのみの場合、実体がある扱いにはならないのです(リスト7.6)。
リスト7.6: 幽霊namespace
declare namespace ghost { interface Test { str: string; } } // 型としてはしっかり存在していてアクセスできる let test: ghost.Test; test.str; // 実体としては存在していない! // invalid.ts(13,17): error TS2304: Cannot find name 'ghost'. let notExists = ghost; export { }
これを活用して大量のインタフェースをもつようなライブラリの定義をひとまとまりにできます。
実際の例を見てみましょう。リスト7.7はjQueryの型定義ファイルからの抜粋(および一部改変)です。
リスト7.7: 実際のjQueryの型定義の例
interface JQuery { addClass(className: string): JQuery; html(htmlString: string): JQuery; val(): any; empty(): JQuery; append(content1: JQuery, ...content2: any[]): JQuery; appendTo(target: JQuery): JQuery; } interface JQueryStatic { ajax(settings: JQueryAjaxSettings): any; (selector: string, context?: Element): JQuery; (element: Element): JQuery; } interface JQueryAjaxSettings { data?: any; type?: string; url?: string; } interface JQueryPromise<T> { state(): string; then<U>( fullfill: (value: T) => U, reject?: (...reasons: any[]) => U ): JQueryPromise<U>; } interface JQueryDeferred<T> extends JQueryPromise<T> { reject(...args: any[]): JQueryDeferred<T>; resolve(value?: T, ...args: any[]): JQueryDeferred<T>; } declare var $: JQueryStatic;
トップレベルに複数の型がいくつも散乱してしまう点がよくありません。それにJQuery
というprefixが乱舞していて目を惑わせます。ライブラリ内部でAPI同士が参照する場合でも引数や返り値にプリフィクスが必要だと面倒です。IDE上で型注釈を手書きするときも候補がたくさんサジェストされてしまうことでしょう。
これを幽霊namespaceを使って書きなおしてみます(リスト7.8)。
リスト7.8: 幽霊namespaceを使ってみた
declare namespace jquery { interface Element { addClass(className: string): Element; html(htmlString: string): Element; val(): any; empty(): Element; append(content1: Element, ...content2: any[]): Element; appendTo(target: Element): Element; } interface Static { ajax(settings: AjaxSettings): any; (selector: string, context?: Element): Element; (element: Element): Element; } interface AjaxSettings { data?: any; type?: string; url?: string; } interface Promise<T> { state(): string; then<U>( fullfill: (value: T) => U, reject?: (...reasons: any[]) => U ): Promise<U>; } interface Deferred<T> extends Promise<T> { reject(...args: any[]): Deferred<T>; resolve(value?: T, ...args: any[]): Deferred<T>; } } declare var $: jquery.Static;
インタフェース名が短く、かつわかりやすくなりました。やっぱり、シンプルなほうがいいですね。
もちろん、無理に幽霊namespaceを使う必要はありません。クラスや変数や関数などを持ち、通常の実体をもつnamespaceが存在している場合は、そのnamespaceに相乗りしてしまったほうが楽でしょう。
どうしてDefinitelyTyped上にある型定義ファイルで幽霊namespaceを使っていないものが多いのかって?よい質問です。ひとつは幽霊namespaceの認知度が低いこと、もうひとつは型定義ファイルの大幅な書き換えは互換性の破壊を生み出すからです。先で説明しましたが、インタフェースは定義の統合ができます。この性質を利用して定義の拡張を行っているので、うかつにJQueryStaticからjquery.Staticに型名を変更するとjQueryの型定義に依存しているさまざまなライブラリの色々なところが壊れてしまいます。特にjQueryプラグインはインタフェースを拡張する形で型定義するのでその量たるや…。
ともあれ、過去の定義との互換性を壊すことに繋がるため、途中から幽霊namespaceに切り替える選択は難しくなります。可能であれば最初から幽霊namespaceを使うようにしましょう。将来的には、このパターンの検出はtslintなどで機械的に行えるようにしたいところですね。
少し前の文章であんだけインタフェースを持ち上げといてこれかぁ!?と思われたかもしれませんが、なんでもかんでも乱用すればいいってものではありません。
具体的にはnamespace的な構造をインタフェースを使って作ってはいけません(リスト7.9)。
リスト7.9: namespaceを使えばいいのにインタフェースで表現してしまう。何故なのか…
interface Foo { bar: FooBar; } interface FooBar { buzz: FooBarBuzz; } interface FooBarBuzz { str: string; } declare var foo: Foo; // foo.bar.buzz.str という使い方ができる。わかりにくくてユーザは死ぬ。
この型定義ファイルを読み解いて一瞬で使えるのは、元のJavaScriptコードを熟知している人だけでしょう。少なくとも、この型定義ファイルをヒントに実際のコードを書くことには大いなる苦痛を伴います。素直にリスト7.10のように書きましょう。
リスト7.10: 素直にこうしよう
// 素直にこうしよう! declare namespace foo.bar.buzz { let str: string; }
通常リスト7.11のような型定義ファイルを書こうとはしませんが、このようなコードが必要になる場合が稀にあります。関数としても呼べるし、namespaceのようにも振る舞うオブジェクトの型定義を作成したいときです。
リスト7.11: 関数・namespace どっちなの?
// assertは関数としても呼べるしnamespaceのようにも見える assert(foo === "foo"); assert.ok(value);
呼び出し可能で、プロパティをもつ場合、すぐに考えつく型定義はリスト7.12か、リスト7.13でしょう。
リスト7.12: こうしてしまいたい、気持ち
declare var assert: { (value: any): void; ok(value: any): void; };
リスト7.13: 匿名型注釈よりはマシ
declare var assert: Assert; interface Assert { (value: any): void; ok(value: any): void; }
たしかに、この定義でも動きます。正直なところassert関数だけの定義だとこのままでもいい気がしますが。
しかし、これには別のよいやり方があるのです(リスト7.14)。
リスト7.14: 関数とnamespace、両方やらなきゃいけないのが辛いところだ
declare function assert(value: any): void; declare namespace assert { function ok(value: any): void; }
関数とnamespaceを同名で宣言できる手法です。メリットは階層構造を素直に表現できることと、前項で説明した幽霊namespaceの書き方を併用できるところです。
この手法は、実際にpower-assertの型定義ファイル*8でも利用されています。リスト7.15に抜粋および改変したものを示します。
リスト7.15: 関数+namespaceの実例
declare function assert(value: any, message?: string): void; declare namespace assert { export function deepEqual(actual: any, expected: any): void; export function notDeepEqual(acutal: any, expected: any): void; export interface Options { assertion?: any; output?: any; } export function customize(options: Options): typeof assert; }
外部に公開されている関数はassert
のみで、そこにプロパティを追加している形式です。namespaceにOptionsインタフェースがうまく取り込まれています。余計な名前を階層の浅いところにバラ撒かず、厳密さも損なっていません。この書き方は、よく登場するパターンなので覚えておきましょう。
実は、この手法は型定義ファイルだけではなく通常のTypeScriptコードでも使えます(リスト7.16)。
リスト7.16: 関数が先、namespaceは後!絶対!
function test() { return "test!"; } namespace test { export function func() { return "function!"; } }
コンパイル結果のリスト7.17を見ると、なぜ関数が先でnamespaceが後、という決まりになっているかがわかります。
リスト7.17: JSとして正しい構造だ
"use strict"; function test() { return "test!"; } (function (test) { function func() { return "function!"; } test.func = func; })(test || (test = {}));
クラスの型定義を行う方法を解説します。歴史的経緯により、TypeScriptではクラスの型定義を行う時に2つの代表的なやり方が存在しています。まずはその2つのやり方を見てみましょう(リスト7.18)。
リスト7.18: 素直にクラス定義 vs インタフェース+変数
// A. クラスを定義する declare class TestA { } // B. クラスの分解定義 変数 + インタフェース2つ declare let TestB: TestBConstructor; interface TestBConstructor { new(): TestB; } interface TestB { }
こんな感じです。クラス定義をするほうが素直ですね。
過去にはこの2つのやり方にそれぞれメリット・デメリットがありました。しかし、現在のTypeScriptでは大幅に制限が緩和されたためメリット・デメリットの面で考える必要はなくなってきました(リスト7.19)。よい時代になったものです。
リスト7.19: 相互運用性がある!
// classはopen-endedになったため同名のinterfaceで拡張可能に class Person { name: string; } interface Person { age: number; } let p: Person = new Person(); // 両方アクセス可能! console.log(p.name, p.age); // interfaceを使ったクラスの構成でも interface AnimalConstructor { new(): Animal; } interface Animal { speak(): string; } /* tslint:disable:variable-name */ let Animal: AnimalConstructor = class { speak() { return "???"; } }; /* tslint:enable:variable-name */ // Animalはただの変数だが問題なく継承できる! class Cat extends Animal { speak() { return "meow"; } } let cat: Cat = new Cat(); console.log(cat.speak()); export { }
正しいライブラリの使い方を導く気持ちを心に秘めて、リスト7.20を見てください。
質問:どれが一番、元々の関数の仕様がわかりやすいですか?
リスト7.20: 書き方あれこれ
// 同じ実装に対して、どの型定義が一番便利かな? // 1関数でget, set両方の役目を果たす場合… // getのとき setのとき 仕様が違うことがよく分かる declare function valueA(value: any): void; declare function valueA(): any; // setのときも値が取れる気がする…? declare function valueB(value?: any): any; // 詳細が不明だ…! declare let valueC: Function;
答え:一番最初の方法。
JavaScriptのライブラリは1つの関数にさまざまな使い方をさせようとする場合がままあります。つまり、1つの関数が複数の顔をもつということです。その顔ひとつひとつに個別の型定義を割り振ってやるテクニックをオーバーロードと呼びます。
なおTypeScriptコードを書くときはオーバーロードをあまり使わないほうがよいスタイルです。実装が煩雑になってしまいますからね。素直にメソッドを分けましょう。
union typesを使うとリスト7.21のように書くこともできます。簡単な例だとunion typesのほうがよいと思いますが、見た目が煩雑になるケースではどっちがいいかは判断が分かれるところです。
リスト7.21: うーん、どっちがいいかは難しい
// union types未使用 declare function hello(word: string): string; declare function hello(callback: () => string): string; hello("TypeScript"); hello(() => "function"); // union typesあり declare function bye(word: string | { (): string; }): string; bye("JavaScript"); bye(() => "function");
もう一例見てみます(リスト7.22)。union typesとoverloadの両方が選択肢に入る場合、現時点ではunion typesを選んだほうがよい場合があります。
リスト7.22: overloadとunion typesは相性がよくない
declare function funcA(word: string): string; declare function funcA(num: number): string; let obj: string | number = null as any; // stringかnumberを渡さなければならない場合 string | number はコンパイルエラーになる // 本来であれば、受け入れてほしいのだけど… // error TS2345: Argument of type 'string | number' // is not assignable to parameter of type 'number'. // Type 'string' is not assignable to type 'number'. funcA(obj); // 元の定義がunion typesならもちろんOK declare function funcB(word: string | number): string; funcB(obj);
あまり言及されることがないのでここで触れておきます。モジュールの型定義はopen endedですのでリスト7.23とリスト7.24のようなコードが書けます。めでたい。
リスト7.23: モジュール定義を後から拡張可能
// モジュールの定義の統合ができます declare module "foo" { let str: string; } declare module "foo" { let num: number; }
リスト7.24: 統合された定義が使えます
import * as foo from "foo"; foo.str; foo.num;
既存のライブラリに勝手にメソッドを生やす(=型を拡張する)ようなライブラリがあります。DefinitelyTypedではモジュールの型定義を容易に拡張するために、モジュールの型定義とそれとは独立したnamespaceを組み合わせて使うパターンがあります。たとえば、lodashやjQueryのようなグローバルな名前空間に変数を生やすような場合に、いまだに有効です。
もしも型定義ファイルを書いていて具体的な型がわからないとき、頭を使わずにとりあえずコンパイルを通したいときは、素直にany
を使いましょう。こういったシチュエーションで、稀にObjectを指定する人がいます。プロトタイプチェーンの頂点にいるObjectをとりあえず据えておこう!という考えかもしれませんがこれは悪いやり方です。
関数の引数にObjectや{}を指定するのは、どういう性質の値がほしいのかを述べていません。本当にどのような値でも受け入れるのであれば、anyにするべきです。
関数の返り値にObjectや{}を指定しても有用なプロパティが存在しないため型アサーションでもって適切な型にキャストするしかありません。これはanyを指定するのと同程度に危険で、なおかつanyより検出しにくいです。素直にanyを使いましょう。
筆者は今のところ、Objectや{}が型注釈として適切な場面を見たことがありません*9。大抵の場合は、適切な型を定義してそちらを参照するほうが優れています。
[*9] 第3章「型は便利だ楽しいな」で触れたオブジェクト限定型(object)が適切な場合は稀にある…かも?
そしてanyを使うことに気後れするのであれば、よく調べて適切な型定義を与えるのがよいでしょう。
もしライブラリにしっかりしたドキュメントがあるのであれば、実装コードから型定義ファイルを起こすのではなく、ドキュメントをベースに作成しましょう。Visual StudioなどのIDEでは、型定義ファイル上に書かれたJSDocコメントも利用時に表示してくれる場合があります。そのため、型定義を起こしつつ、あわせてJSDocを記述していくとよいでしょう。
サンプルをテスト用コードとしてTypeScriptコードに移植し、ドキュメントどおりの記述が可能かも確かめるとよいです。型定義ファイルは書き起こしたけれどもドキュメント中に書かれている利用例のコードをコンパイルしてみて失敗するようであれば、それは悪い型定義だといえます。たまにドキュメントのほうが間違っているときがありますが、その場合は本家に修正のpull requestを送るチャンスです。
世の中、ドキュメントにコストをあまり掛けることのできないプロジェクトも多くあります。そのため、この指針は絶対的なルールではありません。この場合、コードから型定義ファイルを起こすことになるのは仕方のないことです。
optionalとは、値が渡されるかどうかの指標であって、コールバックを受け取った側が使うかどうかではありません。ここを勘違いすると、"コールバックに値が渡されるが別に使わなくてもいいよ"マークとしてoptionalを使ってしまうのです。
例を見てみましょう(リスト7.25)。
リスト7.25: optionalはもしかしたら値がないことを表す
// 良い例 declare function readFileA( filePath: string, listener: (data: string) => void): void; // 悪い例 declare function readFileB( filePath: string, listener: (data?: string) => void): void; // 使ってみよう! readFileA("./test.txt", data => { // ここでのdataは必ず実体がある console.log(data.toUpperCase()); }); readFileB("./test.txt", data => { // ここでのdataはundefinedかもしれない… チェックしなければダメ if (!data) { data = "not found"; } console.log(data.toUpperCase()); }); // 引数を無視するのは自由 optionalにする理由にはならない readFileA("./test.txt", () => { console.log("done"); }); readFileB("./test.txt", () => { console.log("done"); });
両方とも、ファイルの読み取りを行うための関数を型定義として書き起こしたものです。readFileはdataが省略不可、readFileOptはdataが省略可能(optional)になっています。これはreadFileOptではdataがundefinedになるかもしれないことを表します。dataがundefinedかもしれないため、if文などで中身をチェックし、undefinedだった場合の対応を入れなければなりません。本当にundefinedになりうるのであれば省略可能にするか、union typesでundefinedを与える必要があります。しかし、そうではなく必ずdataの値が渡されてくる場合は、無用なチェック処理が発生することになります。
間違えないよう、留意しましょう。
最初にまとめを書いておきます。
まとめ:元のJavaScriptコード中にdefaultの文字がないならimportのdefaultは使うな。
現在JavaScriptのモジュール仕様は過渡期にあります。ECMAScriptでモジュールの記法や考え方は定義され、ブラウザでも実装されはじめました。しかし、CommonJS形式のモジュールとの互換性なんてECMAScriptの仕様には含まれていません。
そのためにTypeScriptやBabelなど、各種トランスパイラ毎にECMAScriptとCommonJS間の変換方法は食い違っています。TypeScriptが正しいのかBabelが正しいのかという議論は、そもそも仕様が不明なので成立しません。TypeScriptもBabelもECMAScriptなモジュール記法からCommonJS形式などへの変換ルールを定めているため、我々はその特徴を知り、正しく使いこなす必要があります。
まずはTypeScriptで書いたコードがどのようなCommonJS形式のコードに変換されるかを見てみます(リスト7.26、リスト7.27)。
リスト7.26: 関数などを素直にexportする
export function hello(word = "TypeScript") { console.log(`Hello, ${word}`); } export function bye(word = "JavaScript") { console.log(`Bye, ${word}`); }
リスト7.27: CommonJS形式ではexports.xxx = となる
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); function hello(word = "TypeScript") { console.log(`Hello, ${word}`); } exports.hello = hello; function bye(word = "JavaScript") { console.log(`Bye, ${word}`); } exports.bye = bye;
単純でわかりやすいですね。
次にCommonJSでのexports.module = ...;
形式(export.moduleへの代入)の記法を見てみます(リスト7.28、リスト7.29)。
リスト7.28: export = ... と書く
function hello(word = "TypeScript") { console.log(`Hello, ${word}`); } // CommonJSの exports.module = hello; 相当 // 外からこのモジュールを参照した時のオブジェクト自体を差し替える export = hello;
リスト7.29: exports.module = ... となる
"use strict"; function hello(word = "TypeScript") { console.log(`Hello, ${word}`); } module.exports = hello;
この変換は重要です。変換結果から逆に考えるとJavaScriptでexports.module = ...;
の形式を見たらTypeScriptではexport = ...;
という型定義に書き起こす必要があります。
理解を深めるためNode.jsでのCommonJSの実現方法について該当のコードを抜粋*10します(リスト7.30)。
リスト7.30: Node.jsのモジュールの実現方法
NativeModule.wrap = function(script) { return NativeModule.wrapper[0] + script + NativeModule.wrapper[1]; }; NativeModule.wrapper = [ '(function (exports, require, module, __filename, __dirname) { ', '\n});' ];
大変シンプルなコードが出てきました。Node.jsにおいて、モジュール固有の変数というのはモジュールのオリジナルのコードの前後に2行付け足して、evalしているだけなのです。なので、Node.js初心者がたまにやりがちなexports = ...;
というコードは間違いです。単に変数の値を差し替えているだけなので当然ですね。外部に変更を露出させるには、何かのプロパティの変更(つまりmodule.exports = ...;
)でなければなりません。
互換性の話に戻ります。このexport = ...;
の記法に対応した"正規の"importの書き方は先ほど見たimport xxx = require("...");
形式です。これを無理やりECMAScript形式のimport文に書き直すとリスト7.31になります。
リスト7.31: import モジュール全体 as 名前
// モジュール全体をutilに割当て import * as util from "./util"; // この書き方は誤り util.ts にdefaultエクスポートはない // error TS1192: Module '"略/util"' has no default export. // import util from "./util"; // Hello, CommonJS と表示される util("CommonJS");
ECMAScript形式でのimportは若干良くなくて、export =
する対象が変数ではない場合、エラーになるためワークアラウンドが必要です(リスト7.32)。
リスト7.32: 同名のnamespaceを被せてごまかす
function hello(word = "TypeScript") { console.log(`Hello, ${word}`); } // 呼び出し元でエラーになるのを防ぐ 同名のnamespaceを被せてごまかす // error TS2497: Module '"略/util"' resolves to a non-module entity // and cannot be imported using this construct. namespace hello { } export = hello;
いまいち優雅ではありませんね。この場合は無理にECMAScriptのモジュール記法を使わないほうが無難かもしれません。世間的にも意見が分かれるところです。
さて、ここで問題になる点がTypeScriptとBabelでmodule.exports = ...;
形式のモジュールを利用する際、どうECMAScript形式にマッピングするかの解釈が異なる点です。Babelの変換結果を見てみます。リスト7.33をコンパイルするとリスト7.34(リスト7.35)となります。
リスト7.33: Babelで変換する前のコード
import util from "./util"; util();
リスト7.34: Babelで変換した結果のコード
"use strict"; var _util = require("./util"); var _util2 = _interopRequireDefault(_util); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } (0, _util2.default)();
リスト7.35: Babelで変換した結果をわかりやすく書き直す
"use strict"; var util = require("./util"); if (!util || !util.__esModule) { util = { default: util }; } util.default();
Babelは、module.exports = ...;
形式のコードに対して特別な配慮を行い、import util from "./util";
形式でも動作します。TypeScriptがimport * as util from "./util";
形式しか許していないため、ここに齟齬があります。
ECMAScript形式+BabelのコードをTypeScriptから参照したり、ECMAScript+TypeScriptのコードをBabelから参照したりすることには大きな問題はありません。しかしmodule.exports = ...;
なコードの取り扱いには注意が必要なのです。
この話題はDefinitelyTypedでよくあるトラブルの1つで、TypeScript+Babelの両方を組み合わせて使うユーザからこのあたりがごっちゃになったコードや修正が来ます。レビューする側としては「いやお前の環境では動くかもしれんが大抵のビルド手順では動かんのじゃ」となり、修正してくれるまで取り込むことはありません。TypeScriptではexports.default = ...
とされているコードのみexport default ...
という型定義を与えてよいのです。元のJavaScriptコード中にdefaultの文字がないならimportのdefaultは使うな。ということです。
インタフェースやクラスのインスタンス単体をモジュールの外側に見せたい場合、リスト7.36のように書きます。
リスト7.36: 実はインタフェースBarも外から見えない
declare module "bar" { interface Bar { num: number; } // この_は外部からは参照できない。exportしてないので。 let _: Bar; export = _; }
呼び出し側ではリスト7.37のように使います。importした値がインタフェースFooのインスタンスになっていることがわかります。
リスト7.37: 使うとき。インタフェースBarのインスタンスが得られる
// b は "bar" の Barのインスタンス だよ! import * as b from "bar"; b.num;
よくやりがちな誤りはリスト7.38のような書き方をしてしまうことです。インタフェースのインスタンスをexportしたつもりが型がexportされてしまうのです。
リスト7.38: それは値ではなくて型だけexportしているぞ!
declare module "buzz" { interface Buzz { num: number; } // よくやりがちな過ち export = Buzz; }
こういう悲しい目を回避するには、型定義ファイルのテストが有効です。型定義ファイルを書いたら適当なユースケースに当てはめて意図どおりコンパイルできるか確かめてみましょう。
グローバルに変数が展開されるタイプとモジュールとしての利用が両立しているタイプのライブラリについて考えます。具体的にUMD (Universal Module Definition)と呼ばれる形式*11です。ライブラリ内部でモジュールとしての使い方が想定されているのか、そうではないのかを判断し展開の方法を変えます。
TypeScriptではこういうパターンのときに使いやすい型定義ファイルの記述方法があります。しかし、TypeScript 2.0.0以前は任意の場所においてある型定義ファイルを特定の名前のモジュールだと認識させる方法がなかったため、役に立ってはいませんでした。この形式が使われているのはDefinitelyTypedの@typesパッケージシリーズ(本書執筆時点ではtypes-2.0ブランチ)だけではないでしょうか。
説明のためにstrutilとstrutil-extraという架空のライブラリについて考えてみます。strutilはrandomizeString関数を提供します。strutil-extraはhappy関数を提供し、strutilを拡張します。
まずは型定義ファイルを見てみましょう(リスト7.39、リスト7.40)。ちょっと見慣れない書き方ですね。
リスト7.39: typings/strutil/index.d.ts
// importされなかった場合、globalにstrutilという名前で展開する export as namespace strutil; // 普通の型定義 declare module "..." の中と同じ書き味でよい export interface Options { i?: number; } export declare function randomizeString(str: string, opts?: Options): string; // グローバルな要素の拡張 declare global { // 既存のstring型にメソッドを生やす interface String { randomizeString(opts?: Options): string; } }
リスト7.40: typings/strutil-extra/index.d.ts
// 他のモジュールの型定義を参照する import * as strutil from "strutil"; export as namespace strutilExtra; export declare function happy(str: string): string; // 他のモジュールの拡張 declare module "strutil" { // 既存の要素を拡張できる interface Options { reverse?: boolean; } // 自分ではないモジュールに勝手に新規の変数や関数を生やしたりはできない // 定義の拡張のみ可能 // error TS1038: A 'declare' modifier cannot be used // in an already ambient context. // export declare let test: any; } declare global { interface String { happy(): string; } }
既存モジュールの定義の拡張もできています。この形式だと、どのライブラリを拡張しているのか明示できるところが利点です。
これらをimport ... from "strutil";
するためのtsconfig.jsonを確認しておきます(リスト7.41)。
リスト7.41: tsconfig.jsonの例
{ "compilerOptions": { "module": "commonjs", "target": "es5", "noImplicitAny": true, "baseUrl": "./", "paths": { "strutil": ["./typings/strutil/"], "strutil-extra": ["./typings/strutil-extra/"] } }, "exclude": [ "node_modules" ] }
baseUrlとpathsの指定があります。TypeScript 2.0.0からこうして任意の場所の型定義ファイルを任意の名前に紐付けられるようになったため、ローカル環境でも利用しやすくなりました。
次に前述の型定義ファイルを利用する例を見てみます。まずはグローバルに展開される例です(リスト7.42)。
リスト7.42: lib/bare.ts
// UMD形式のライブラリがglobalに展開されたときの動作に相当する // import, export句がない場合、globalのstrutilが参照できる strutil.randomizeString("TypeScript"); strutilExtra.happy("TypeScript"); // globalのStringも拡張されている "TypeScript".randomizeString(); "TypeScript".happy(); // import、export が存在すると、ちゃんと読み込め!と怒られる // error TS2686: Identifier 'strutil' must be imported from a module // error TS2686: Identifier 'strutilExtra' must be imported from a module
export as namespace ...
形式を使わないUMD形式の対応方法もありますが、importと混ぜるとエラーになるところがよいですね。
モジュール形式も見てみましょう(リスト7.43)。モジュールとしてimport句の対象にできています。
リスト7.43: lib/module.ts
// UMD形式のライブラリがglobalに展開されたときの動作に相当する // importした時、普通のモジュールとして振る舞う import { randomizeString } from "strutil"; import { happy } from "strutil-extra"; randomizeString("TypeScript"); happy("TypeScript"); // strutil-extra で追加したパラメータも反映されている randomizeString("TypeScript", { i: 11, reverse: true, // これ }); // globalのStringも拡張されている "TypeScript".randomizeString(); "TypeScript".happy();
この形式がどこまで普及するかはわかりませんが、時とともにDefinitelyTyped内部でも見かける頻度が増えていくでしょう。ファイル名を見ただけではどういう名前に解決されるかがわかりにくいところだけは注意が必要です。
やった!型定義ファイルが書けたぞ!出来高に満足する前に、もう少しだけやっておきたいことがあります。それが、--strict
をつけてのコンパイルの試運転とtslintによるチェックです。
lintとは、プログラムを静的に解析してバグになりそうな箇所や悪いコードスタイルを見つけてくるツールを指します。TypeScriptではtslint*12というプログラムが一般的に使われています。
tslintはコンパイルだけでは見つけきれない、悪いにおいのするコードを検出してくれます。tslintでは頻繁に新しいルールが追加されるため、本書では詳しくは取り上げません。その時々の最適な設定を突き詰めてみてください。
利用には設定ファイルを必要とします。今のところ、TypeScriptにおける統一見解は存在していないのでtslintが使ってる設定ファイル*13かTypeScript本体のtslint.json*14を参照するとよいでしょう。
ようこそ!DefinitelyTyped*15へ!メンテナのvvakameです。とかいいつつここ最近くらいは筆者はツール類のメンテ以外をサボっていて、Microsoftのメンバーがpull requestの処理を行ってくれています。
DefinitelyTypedではさまざまな型定義ファイルを取り揃えてございます!世界中の人々が作った型定義ファイルは集積され、@typesなどを介して広く利用されています。
貴方が作った型定義ファイルも世界中の人々に使ってほしいとは思いませんか?もしくは、あなたがいつも使っている型定義ファイルのバグを治したい…そんな気持ちになることもあるでしょう。その思い、すべてDefinitelyTypedにぶつけてみましょう!
本書を読んでいただいた紳士淑女の皆様は、感じのよい型定義ファイルが書けるようになっています。品質と時間のトレードオフを考えつつ、上品な型定義ファイルを提供していただきたいです。
DefinitelyTypedはGitHub上のリポジトリなので追加、修正についてはpull requestをご利用ください。pull requestを送る前に、README.md*16とPULL_REQUEST_TEMPLATE.md*17を読んでおくとよいでしょう。
[*17] https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/.github/PULL_REQUEST_TEMPLATE.md
簡単なチェックポイントは次のとおりです。
npm run lint パッケージ名
で問題が検出されないこと新規型定義ファイルはDefinitelyTypedリポジトリをforkし、npm install -g dts-gen
で入るdts-genコマンドを使い作成します。これには推奨設定のtsconfig.jsonとtslint.jsonと、index.d.tsと簡単なテスト用ファイルも作成されます。詳しくはhttps://www.npmjs.com/package/dts-genを参照してください。
新規に作成する場合、DefinitelyTypedリポジトリのtypesディレクトリ配下に対象となるnpmパッケージ名と同名のディレクトリに各種ファイルを収めます。もし対象となるパッケージがscopedパッケージの場合、たとえばvvakame/foobar
に対して型定義ファイルを作成するのであればtypes/vvakame__fobar
ディレクトリにファイルを作成します。
現在のDefinitelyTypedでは、dtslint*18というtslintを拡張したルールセットでチェックを行っています。これは、npm run lint パッケージ名
としたときに裏側で動きます。
この他、人力じゃないと判別が付かないようなコードの良し悪しについてチェックします。たとえば、幽霊モジュールを使ったほうがコードがきれいになるのでは?とかベストプラクティスにしたがっているか?などです。
逆に、ここに書かれていないことはあまり見ていません。たとえば、ライブラリの実装全体に対する型定義ファイルのカバー率やanyの多さなどはあまり見ていません。それらは後から別の人が補ってくれる可能性があるからです。一人でやりきらなくてもいいよな!という発想ですね。もちろん最初に高品質高カバー率のものが出てきたほうが「やりおる!」と感心はします。
既存の型定義の変更の場合、コードスタイルの変更や破壊的変更については取り込みは比較的慎重に行われます。@dt-botというボットが自動的にレビューするべきであろう人にメンションしてくれるので、反応を待ちましょう。
では皆様のpull request、お待ちしています!
自分の作ったライブラリをnpmに公開する時のベストプラクティスについて説明します。ここで説明する内容はTypeScriptによってコードが書かれているライブラリを前提とします。また、npmにパッケージを公開するための基本的な説明はここでは行いません。
ポイントは.tsファイルをリリースに含めないこと、.d.tsファイルをTypeScriptコンパイラに生成させること、.d.tsファイルをTypeScriptコンパイラが自動的に見つけられるようにすることです。
まずは.tsファイルをリリースに含めない理由について説明します。これは、TypeScriptコンパイラの探索順序が.tsファイル、.tsxファイル、.d.tsファイルだからです。.d.tsファイルも公開していたとしても、.tsファイルが存在しているとそちらが先に発見され、コンパイル処理が走ってしまいます。TypeScriptコンパイラのバージョンが上がった時にソースコード(.ts)の修正が必要になるケースは多いですが、型定義ファイル(.d.ts)が影響を受けるケースは稀です。つまり、自分のライブラリをより安定したものとするためには、.tsファイルをリリースに含めないほうがよいわけです。そのために.npmignoreファイルにリスト7.44の記述を追加します。
リスト7.44: .npmignoreで.tsコードを排除し.d.tsはパッケージング対象へ
# libディレクトリ配下でコードが管理されている場合 lib/**/*.ts !lib/**/*.d.ts
.d.tsファイルをTypeScriptコンパイラに生成させるための作業は、.tsコードをコンパイルするときに--declaration
オプションを利用するだけなので簡単です。
次に.d.tsファイルをTypeScriptコンパイラが自動的に見つけられるようにする理由ですが、これは単純に使いやすいからです。実現するためにはTypeScriptコンパイラの検索パスに自身の型定義ファイルが入るようにします。
そのための方法はいくつかあります。
1つ目はNode.jsが実行時にパッケージのrootにあるindex.jsを最初に読み込もうとする挙動に似せた動作です。2つ目と3つ目はほぼおなじやり方ですが、3つ目のほうが最近追加されたやり方です。typingsとtypesプロパティの両方が存在する場合はtypingsプロパティが優先されます。
筆者はもっぱら、1つ目の方法を使いindex.d.tsとindex.jsを手書きしています。これはpackage.jsonに色々と書くよりも一般的なルールに従うのを良しとしているためです。
実例については筆者のtypescript-formatter*19リポジトリを参照してください。
[*19] https://github.com/vvakame/typescript-formatter