読者です 読者をやめる 読者になる 読者になる

Hatena Developer Blog

はてな開発者ブログ

TypeScript の型定義ファイルと仲良くなろう

はじめに

こんにちはアプリケーションエンジニアの id:t_kyt です。

このエントリでは TypeScript (以下 TS)における型システムの概要に触れつつ、型定義ファイルに何が書かれているかを理解するのに必要な知識を解説していきます。ある程度TSを書いたことあるが、なんとなく .d.ts ファイルをダウンロードしてきて使っている人向けです。

TypeScriptの型システム

型定義ファイルの書き方読み方に入る前にまず TS の型のシステムについて軽く触れておきます。

この章を読まずとも定型的に型定義ファイルの読み方書き方を覚えることはできますが、何が起こっているのかわからないと応用が効かないと思うので一読しておくことをおすすめします。

Declaration space

TSには declaration space という概念が存在します。同一の declaration space 上で同じ名前の entity (宣言した変数や型など)があった場合、コンパイルエラーとなります。ただし宣言が open-ended な場合や特別に振る舞いが定義されている場合はその限りではありません。 open-ended に関しては後ほど解説します。

declaration space には3種類あり、それぞれ ValueTypeNamespace です。

var X: string;    // Value named X

type X = number;  // Type named X

namespace X {     // Namespace named X  
    type Y = string;  
}

結果

このコードはすべて同一の X という名前で宣言されていますが、 declaration space が異なっているため、コンパイルエラーにならずそれぞれ有効です。

同一空間ではコンパイルエラーになるので

type X = number;
type X = number;

結果

のようにするとエラーになります。

var X: string;    
var X: string;

こちらの場合も、Valueに関して同一の declaration space に同じ識別子で宣言していることになりますが、振る舞いが特別に定義されているためエラーにはなりません。

Multiple declarations for the same variable name in the same declaration space are permitted, provided that each declaration associates the same type with the variable.

5.2.1 Simple Variable Declarations より

また namespace についても同一の declaration space に同一識別子で宣言してもコンパイルエラーになりません。 namespace の場合は後述する open-ended の仕様によるため例外的扱いになります。

(例外的なものも多いですが、原則的には同じ declaration space には同じ識別子で宣言はできません。)

ある宣言がされた時、どの declaration space に宣言されるかは Declaration Type によって変わります。

Declaration Type Namespace Type Value
Namespace X X
Class X X
Enum X X
Interface X
Type Alias X
Function X
Variable X

Declaration Mergingより。

Declaration Type は宣言の種類です。チェックマークはその各宣言がどの declaration space に属するかを表しています。

このうち実際の JS のコードになるのは Value と Namespace です。Type は生成された JS のファイルには影響せず、TS の世界でしか影響がありません。 Namespace の場合は Namespace 内に Value がないと何も生成されません。

参考: 10.1 Namespace Declarations

Open-ended

open-ended とは同じ装飾名の宣言があった時、自動的にマージされる性質のことです。

open-ended な宣言として有名なのは interface 宣言でしょう。

interface Document {  
    createElement(tagName: any): Element;  
}

interface Document {  
    createElement(tagName: string): HTMLElement;  
}

interface Document {  
    createElement(tagName: "div"): HTMLDivElement;   
    createElement(tagName: "span"): HTMLSpanElement;  
    createElement(tagName: "canvas"): HTMLCanvasElement;  
}

このコードはマージされるので

interface Document {  
    createElement(tagName: "div"): HTMLDivElement;   
    createElement(tagName: "span"): HTMLSpanElement;  
    createElement(tagName: "canvas"): HTMLCanvasElement;  
    createElement(tagName: string): HTMLElement;  
    createElement(tagName: any): Element;  
}

とした場合と同等になります。 namespace 宣言も open-ended であるため、前述した例外のような振る舞いとなります。つまり

namespace X {
    type X = string;  
}

namespace X {
    type Y = string;  
}

はエラーとなりません。

さて、 open-ended な宣言がマージされる条件に root container が共通であるということがあります。まずは container の概念について説明します。

ある entity が宣言された時、その container も自動的に決定します。具体的には

namespace N {
    let x = 1;  
}

ここで宣言された x の container は namespace N となります。

また TS では Top-Level に importexport があるファイルが module とされ、 module内の宣言では container はその module になります。例としては以下のようなものです。

export {}
var x = 1;

この場合 x の container はこの module になります。

また、 module 自体の container は global namespace になります。

まとめると

  • ある namespace 内で宣言された entity の container はその namespace
  • ある module 内で宣言された entity の container はその module
  • global namespace 内で宣言された entity の container は global namespace
  • module 自体の container は global namespace

です。

次に root container ですが、root container は宣言が export されているかどうかで変わります。

  • export されていない entity の場合 root container はその entity の container
  • export されている entity の場合の root container はその entity の container の root container

直感的には一番外側の module や namespace が root container ということですが、例を見ないと意味がわからないでしょう。

// A.ts
namespace outer {  
    var local = 1;           // export されていない
    export var a = local;    // outer.a  
    export namespace inner {  
        export var x = 10;   // outer.inner.x  
    }  
}

// B.ts
namespace outer {  
    var local = 2;           // export されていない
    export var b = local;    // outer.b  
    export namespace inner {  
        export var y = 20;   // outer.inner.y  
    }  
}

Top-Level に namespace で宣言した場合、その container は global namespace になります。したがって、2つの outer はどちらも global namespace が root container ということになります。

open-ended のルールが適用されるのは root container が共通で、宣言が同じ declaration space で行われている場合ですから、 outer は条件を満たし定義はmergeされます。

また outer 内で宣言されている innerexport されているので root container は global namespace となり open-ended の性質が適用されます。したがってこの outer の instance type は

{  
    a: number;  
    b: number;  
    inner: {  
        x: number;  
        y: number;  
    };  
}

と同等になります。 export されていない宣言( local )については root container が共通でないためマージされません。(そもそも export していないので外からは見えません。)

ここまでの確認

いろいろ書きましたが、これで TS を書いている時「なんで?」となる(型定義に関する)ハマりポイントが仕様的にどう解釈されているかある程度理解できるようになったかと思います。

これまでの知識の確認をするため、ありがちなコードを例に少し今までの知識を振り返ります。

以下のコードは上手く行かない例です。

import $ from "jquery";

// interface をマージして window.originalFunction() を使えるようにしたい
interface Window {
    originalFunction(string): void
}

window.originalFunction("test"); // => 使えない…

window: Window; // type annotationしてみる

window.originalFunction("test"); // => 使えるようになった!!
window.alert('hello,world');     // => 元から Window あった定義が使えなくなった……

このコードで何が起きているかというと、 Window は新しく宣言されただけでマージされていません。type annotation した時点で window (変数)の方が既存の Window (interface)から新しく定義された Window に書き換わっています。なので window の型は既存の定義か、新しい定義のどちらかになってしまうのです。

では何故マージされないのか?についてですが 既存の Window と新しく定義した Window の root container が異なっているためです。既存の Window の root container は global namespace ですが、上記のコードで宣言された Window の root container は import $ from "jquery"; が存在するため、この module になります。 Top-Level に import を書いた場合 module になること、 export されていない entity の root container の決定の仕方を思い出してください。

ではどうすればいいのかについてですが、 root container が共通になるように宣言すれば良いのです。具体的には別ファイル( module になっていない = importexport が書かれていない)に宣言を移すか、後述する declare global { } を使うといいでしょう。

次に TS では Top-Level に importexport があると module として扱われる確認です。

// x.ts
namespace N {
    export x(): void;
}
// y.ts
namespace N {
    export y(): void;
}

この場合 module にはなっていないので使う側は特に import する必要はなく

N.x()
N.y()

のように即使用できます。使う側も module にはなっていません。また N の定義も統合されるので N 以下に xy が存在する形になります。

対して以下のような場合は module として扱われるので namespace の場合とは違います。

// x.ts
export namespace N {
    export x(): void;
}
// y.ts
export namespace N {
    export y(): void;
}

使う側としては

import {N as N1} from "./x.ts";
import {N as N2} from "./y.ts";

N1.x()
N2.y()

のように使います。使う側も import があるため module として扱われます。

そのファイルがいま module なのか?を意識すると定義ファイルでハマることも少なくなると思います。

型定義ファイルを読み書きできるようになるために

さてここからは、declare の説明をしたあと、型定義ファイルを読み書きできるように典型的な例を紹介していきます。

何が起こっているのかについては前章の知識で大体カバーできるかとおもいます。ただ、型定義ファイルの書き方に関するベストプラクティスは歴史的な経緯が入る部分もあるため深入りはしません。幾つかの典型的な例を示しつつ、標準的な型定義ファイルに対して何をやっているのか理解でき、標準的な書き方ならばできる、また標準的ではない型定義ファイルに対しても「何故」はともかく「何をやっているか」は理解できるようになることを目指します。

declare キーワード

declare キーワードを使うと既存の JavaScript の型情報が表現できます。

例えばグローバルで宣言されたtest()という関数を使うというシチュエーションを考えた時、以下コードは JS としては問題ない(本当にグローバルに test() があれば)ですが、tsc でコンパイルしようと思うとエラーになります。

test()
test.ts(1,1): error TS2304: Cannot find name 'test'.

これはどういうことかというと TS の declaration space に test が宣言されていないということです。ではどうすればいいかというと、ないのであれば宣言すれば良いのです。そんなときに declare キーワードを使います。

declare function test()

declare を使うと JS のコードとして出力されません。これにより既存の test() のコードを上書きすることなく test() が宣言することができますので、ブラウザや既存の JS によって提供される変数や関数を宣言する事ができます。これが ambient (包囲した、取り巻く)宣言です。

宣言すれば、以下のコードはコンパイルが通ります。

declare function test()
test();

みれば分かるように declare 自体は特に type annotations は必須ではありません( --noImplicitAny が有効になっていない場合。有効な場合明示的に any が必要です )。ただ declare は型情報もかけるので書いたほうが便利ということで普通は書きます。ambient 宣言を集めたファイル( .d.ts )は型定義ファイルなどと呼ばれて利用されています。

// x.d.ts
declare function test(): string;

// y.ts
let t = test(); // t は型推論で string になる

.d.ts ファイルはコンパイルした結果、JS のコードは生成されません。というよりは、生成しない宣言しかしてはいけません。

既存のオブジェクトの型定義を拡張する

ここからは今までの知識を利用して実際に定義ファイルを読み書きできるようになるための実例を示していきます。

実行環境がブラウザの場合 window オブジェクトが存在していますが、その型定義を拡張したい場合を考えます。

自分で拡張せずとも標準的なものは Window として typescript/lib/lib.dom.d.ts に定義されていますが、今回は例えば window.fetch() など polyfill した場合や自分で独自のプロパティを生やしているシチュエーションを想定しています。

そういった場合は既に定義されている Window に対して新しい定義をマージすることで対処します。

interface Window {
    fetch(url: string|Request, init?: RequestInit): Promise<Response>;
}

これは window.fetch の例ですが RequestInit などの定義は省略します。これで既に TS が標準で定義している Windowfetch がマージされます。

グローバルなオブジェクトに対する宣言

例えば jQuery を script タグで読み込んだ時のようなグローバルにオブジェクトが存在しているような場合を考えます。その場合

declare namespace $ {
    function x(): void;
    var y: string;
}

のように namespace で対処すると良いでしょう。これにより $.x()$.y みたいな呼び出しが可能になります。なお declare namespace 内では定義された entity はデフォルトで ambient 宣言になるため declare は必要ないです。

少し混乱すると思いますが、実際の DefinitelyTyped で公開されている jquery.d.ts はこの方法では書かれていません。これは歴史的経緯や他の定義ファイルとの依存関係の話になるので、特に気にしないで namespace を使うといいでしょう。

もちろん単なる関数オブジェクトの場合は

declare function f(): void;

のようにするだけで十分です。

ここで1つ今ある定義ファイルを読みやすくするポイントして、 namespace が導入されたのは結構最近なので、以前からある定義ファイルでは module となっている場合があるということを頭に入れておくといいでしょう。ES6のモジュールシステム導入にともなって

  • internal module( module 'N' {} ) → namespace( namespace 'N' {} )
  • external module( module M {} ) → (単に)module( module M {} )

というように用語とキーワードが改められました。意味は同じですが module(旧 external module)と紛らわしいので namespace が推奨されています。

module

npm module を使いたいとき、単に npm i lodash しただけでは TS の世界では import できません。

import * as _ from "lodash";

module の場合は別ファイルにそれ用の宣言が必要になります。

declare module 'lodash' {}

module の後が文字列なのがポイントです。識別子だと namespace になります。

ただし、これだと lodash という module があるという情報しかないので定義を追加していきます。

declare module 'lodash' {
    interface Hoge{}
}

にすると

import * as _ from "lodash";
var hoge: _.Hoge;

がコンパイルを通るようになります。

module も open-ended なためマージすることができます。

declare module 'lodash' {
    interface I {
        x(): string
    }
}

という既存の .d.ts ファイルが存在しているが、自分で定義を追加したい場合は以下のようなファイルを用意すれば良いということです。

declare module 'lodash' {
    interface I {
        y(): string
    }
}

ただし、module が open-ended になったのは最近のことなので、namespace を使って無理やり拡張できるように定義されている場合もあるので頭に入れておくと混乱せずに済むでしょう。

参考: JS資産と型定義ファイル

Export Assignments

Export Assignments(export = )を使った例を見ていきます。

現在の TS では ES2015 形式のモジュールシステムが使えるため、Export Assignmentsで書く必要がない場合もあります。しかし、 export =export default に互換性がないこともあり、既存の定義ファイルの大多数は Export Assignments で書かれていると思うので、むしろ ES2015形式 より多く見かけると思います。

参考: ES6 Modules default exports interop with CommonJS

import sayHello = require("say-hello");
sayHello("Travis");

このように単体のオブジェクトが export されている module の場合、定義ファイルは

declare module "say-hello" {
    function sayHello(name: string): void;
    export = sayHello;
}

のようになります。 export = は単体のオブジェクトを export できます。

import M = require("M");
// ES2015でいうと
// import * as M from "M";

のように利用する module 、つまり M というオブジェクト以下に複数の export されたオブジェクトがぶら下がっているような場合は namespace を Export Assignments で export します。

declare module "M" {

    namespace N {
        function x(name: string): void;
        class C {}
     }

     export = N;
}
import m = require("M");

m.x("Travis");
let C = new m.C();

interface も利用できます。

declare module "M" {

    interface I {
        (): I;
        new (): I;

        x: string;
        y(): void;
    }

    declare var i: I;

    export = i;
}

間違いやすいポイントとしては export = に何を指定するかです。 interface である Iexport = をしてしまうと、 インスタンスではなく型そのものが export されてしまうのでうまくいきません。type annotation された変数を export = しましょう。

Relative or Non-relative module imports

補足ですが、以上はすべて non-relative module の例です。つまり require()from で指定する部分を

import $ = require("jquery");

import * as $ from "jquery";

のように module 名でする場合です。npm の module を利用する場合などはこうする場合が多いと思います。

対して relative module import とは

import Entry from "./components/Entry";

のように相対パスで指定する場合です。自前の module はこちらでしょう。

既存の(自前の) JS を .d.ts ファイルとともに使用する場合は .js ファイルと .d.ts ファイルは同じディレクトリに入れておくのが普通だと思います。その場合、 from 以降で指定されている path が一致しているので、今までの例にある一番外側の declare module M は必要ないです。declare module M は non-relative module import における module 名を指定していると考えてください。

TS における module の解決の仕方も頭に入れておくと理解しやすいです。

参考: Module Resolution

ES2015形式

ES2015 形式の module でも記述することが出来ます。 declare キーワードが付いてても export が書けるので以下のようになります。

// m.ts
export declare function f(): void;
export declare class C {}

この場合 namespace でまとめてから export = みたいなことはせずに済みます。

import する側は

import * as m from "./m";

のように利用します。これは

import m = require("./m");

と同義です。

実際の定義ファイル

これで大体の型定義ファイルのパターンがカバーできたと思います。ここで一つ実際の例(jQuery)を見てみましょう。

declare module "jquery" {
    export = $;
}
declare var jQuery: JQueryStatic;
declare var $: JQueryStatic;

ここより上の部分はひたすら JQueryStatic の定義をしているだけなので割愛します。Top-Level に interface が宣言されているのであまり良いとは言えないのですが、型定義ファイルの書き方のベストプラクティスは事情によりけりなので今は触れないことにします。 参考: JS資産と型定義ファイル

declare var jQuery: JQueryStatic;
declare var $: JQueryStatic;

この部分で global に $ 及び jQuery という変数が存在すること、またその変数は JQueryStatic であるということが TS のコンパイラに伝わります。これで global な jQuery の読み込み(scriptタグで読み込んだ場合など)には対処できます。

しかし、JQuery を npm module として読み込みたい場合これでは不十分です。それを補うのが

declare module "jquery" {
    export = $;
}

この部分です。

この記述により jquery という module が存在することが TS のコンパイラに伝わります。この記述があって初めて

import * as $ from 'jquery'

という module 名の読み込みが可能となります。なぜ interface を直接 export しないのかについては前述のとおりです。

これでめでたく script タグで読み込んだ場合と npm module として扱いたい場合の対応ができました。

既存の定義ファイルを拡張する

基本的には既存のオブジェクトの型定義を拡張するで触れたように open-ended な宣言に対するマージで対処します。 interface だけでなく module( declare module 'M' {} )や namespace( declare namespace N {} )も open-ended であるため、マージされる条件が整えば既存の型定義ファイルを拡張することが出来ます。

ただし export = が使われていた場合注意が必要です。

declare module 'M' {
    namespace N {
        var x:string;
    }
    export = N;
}

このようになっている定義ファイルは外から拡張する方法はありません。なので定義ファイル自体書換える等の処置が必要になります。

ES2016 形式で書かれている場合は外から拡張可能です。

declare module 'M' {
    export var x: string;
}

に対しては別ファイルに

declare module 'M' {
    export var y: string;
}

と書けば定義は拡張されます。

export = が使われていた場合、外部から拡張する術がないのは微妙に困ることが多いですが、TS 2.0 (次期バージョン)以降では以下のような書き方ができます。

import * as M from 'M';

declare module 'M' {
    function x(): void;
}

M.x();

参考: Unable to augment export = function/namespace

ちなみに TS の nightly builds は npm install typescript@next でインストールできます(現行は Version 1.9.0-dev.20160611-1.0 ですが上記の書き方はできるようになっています)。

declare global { } について

TS 1.8 より declare global { }導入されました。この記法により module 内でも global namespace に型を定義を宣言できます。

import $ from "jquery";

// interface をマージして window.originalFunction() を使えるようにしたい
declare global {
    interface Window {
        originalFunction(string): void
    }
}

window.originalFunction("test"); // => 使える
window.alert('hello,world');     // => 使える

先ほど上手く行かない例としてあげたコードもこのように書けばファイルを分割することなく Window を拡張できるようになります。

Typings について

最近、長らく TS の型定義ファイル管理ツールであった TSD が非推奨になり、Typingsが登場しました。

TSD にはいくつが問題点があったのですが、特に型定義ファイルの global な読み込みについてこのエントリと関連深いため触れておきます。

前述の jQuery の例を見るとわかるのですが、 jQuery を module として読み込んだ場合でも $JQueryStatic が global に宣言されてしまいます。 declare が付いているので declaration space は Type となり、実際の成果物である JS には影響を及ぼさないため害がないといえばないですがあまりいいものではありません。たとえば jQuery を import していない場合でも以下のコードは問題なくコンパイルされます。

 $('.xxx').hide();

しかし実際の JS は module 化されているので $ オブジェクトは存在せず、実行時エラーとなります。 これを解決するには import しているファイルでのみ定義ファイルを referenceタグ( /// <reference path="path/jquery.d.ts"> )を使って読み込む等の地道な作業が必要でした。

Typings はこの問題を解決しています。Typingsが提唱する形式の型定義ファイルは module として読み込むか、global に読み込むかを選択できます。module として読み込む場合 Typings 側で自動で global に散らばった定義を隠蔽してくれます。したがって、script タグで読み込んだ場合でも npm module として読み込んだ場合でも両方適切に対処できるようになります。

ただし、 module 化した場合、外から拡張する手段が現状(TS 1.8)では存在しないので定義ファイル自体を書き換えるしかありません。先ほど触れた 1.8.2 での変更が入れば外部からの拡張が可能です。

Typings の形式に対応した型定義ファイルはまだ多くないのですが、オプションを付けることで DT のファイルも使うことができます( module 化はしてくれないので tds で読み込むのと同じになる)。

ちなみに TS2.0 からは npm で型定義ファイルを管理できるようになるため Typings も一時の繋ぎになるかもしれません。 参考:The Future of Declaration Files

おわりに

TS の躓きポイントの1つである型定義ファイルの読み方書き方について、型システムを仕様を少し掘り下げることで何が起こっているのかを解説しました。

TS の型定義ファイルで困ったらむやみに <any> する前に一度立ち止まり正しく型を付けれないかを考えてみましょう。

インターン募集中

hatenacorp.jp

応募締め切りは2016年7月4日(月)までです!奮ってご応募ください!!