TypeScript

TypeScript を使って JavaScript への投資を強化する

Bill Wagner

コード サンプルをダウンロードする

TypeScript プログラミング言語は、実際には JavaScript のスーパーセットです。JavaScript を使用しているということは、TypeScript を既に使用していると言えます。すべての特徴を活かした優秀な TypeScript を記述しているとは言えませんが、JavaScript へのこれまでの投資を (TypeScript が提供する新しい機能を活用する) TypeScript コードベースにスムーズに移行できる可能性があることを意味します。

今回は、アプリケーションを JavaScript から TypeScript に移行する際の推奨事項を紹介します。JavaScript から TypeScript に移行し、TypeScript の型システムを使用して、より優れたコードを記述する方法について説明します。TypeScript のスタティック分析を使用すると、エラーが最小限に抑えられ、生産性が向上します。これから紹介する推奨事項に従うことで、移行時の TypeScript 型システムのエラーや警告を最小限に抑えることができます。

まず、アドレス帳を管理するアプリケーションを例に取り上げます。これは、クライアントで JavaScript を使用する Single-Page Application (SPA) です。ここでは、説明が簡単になるように、連絡先一覧を表示する部分だけを含めました。このアプリケーションでは、データ バインディングとアプリケーションのサポートに Angular フレームワークを使用します。Angular フレームワークは、連絡先情報を表示するためのデータ バインディングとテンプレートを処理します。

アプリケーションは 3 つの JavaScript ファイルから構成されます。app.js ファイルは、アプリケーションを起動するコードを含みます。contactsController.js ファイルは、一覧ページのコントローラーです。contactsData.js ファイルは、表示する連絡先の一覧を含みます。コントローラーと Angular フレームワークが連携して、一覧ページの動作を処理します。連絡先の並べ替えや、連絡先を 1 つ選んでその詳細を表示または非表示にできます。contactsData.js ファイルでは、一連の連絡先をハード コーディングしています。運用環境のアプリケーションの場合、このファイルにはサーバーを呼び出してデータを取得するコードを含めます。今回は、連絡先の一覧をハード コーディングすることで、デモが自己完結できるようにしています。

Angular の使用経験があまりなくても心配することはありません。Angular を使用すると、アプリケーションの移行への着手がいかに簡単かがわかります。アプリケーションは、 Angular のコーディング規約に従います。その結果、アプリケーションを TypeScript に移行する際に、コードを簡単に保持できるようになります。

TypeScript へのアプリケーションの移行は、コントローラー ファイルから始めるのが最適です。有効な JavaScript コードは TypeScript コードでも有効なので、contactsController.js というコントローラー ファイルの拡張子を .js から .ts に変更するだけです。TypeScript 言語は、Visual Studio 2013 Update 2 で最も重要視される言語です。Web Essentials 拡張機能をインストールしている場合は、TypeScript ソースと生成後の JavaScript 出力の両方が同じウィンドウに表示されます (図 1 参照)。

The TypeScript Editing Experience in Visual Studio 2013 Update 2
図 1 Visual Studio 2013 Update 2 での TypeScript の編集操作

TypeScript 固有の言語機能をまだ使用していないため、2 つのビューはほぼ同じです。コードの末尾に追加されたコメント行で、TypeScript のアプリケーションをデバッグするときの情報を Visual Studio に提供します。Visual Studio を使用すると、生成後の JavaScript ソース レベルではなく、TypeScript レベルでアプリケーションをデバッグすることができます。

TypeScript コンパイラが有効な JavaScript の出力を生成しているにもかかわらず、TypeScript コンパイラではこのアプリケーションのエラーが表示されます。これは、TypeScript 言語の優れた特徴の 1 つです。TypeScript が JavaScript の厳密な意味でのスーパーセットであることに起因する自然な結果です。TypeScript ファイルではシンボル contactsApp がまだ宣言されていません。そのため、TypeScript コンパイラはシンボル contactsApp を any 型と見なし、そのシンボルは実行時にオブジェクトを参照するものと想定します。このようなエラーが出力されても、アプリケーションは依然として実行可能で、正常に機能します。

アプリケーションのすべての JavaScript ファイルの拡張子を続けて変更してもかまいませんが、さらにエラーが増える結果になるのでお勧めしません。アプリケーションは機能しますが、多くのエラーが発生すると、TypeScript システムを使用して優れたコードを記述するのが困難になります。一度に作業するファイルは 1 つにして、型情報をアプリケーションに順次追加していきます。その結果、一度に修正する型システムのエラーの数が減少します。クリーンなビルドが用意できた時点で、TypeScript コンパイラがどのような問題を回避できるかがわかります。

contactsApp 用に外部変数を宣言するのは簡単です。既定では、contactsApp は any 型になります。

declare var contactsApp: any;

これでコンパイル エラーは解決しますが、Angular ライブラリでメソッドを呼び出す際のミスを回避することには役立ちません。any 型は、まさにどのような型にもなり得ます。この場合、TypeScript は、contactsApp 変数にアクセスする際に型チェックを行いません。型チェックを行うには、contactsApp の型と Angular フレームワークで定義される型について、TypeScript に指示する必要があります。

TypeScript は、型定義という機能を使用して、既存の JavaScript ライブラリの型情報を有効にします。型定義は、実装のない一連の宣言です。これらの宣言は、型とその API を TypeScript コンパイラに示します。GitHub の DefinitelyTyped プロジェクトには、Angular.js など、よく使われる多くの JavaScript ライブラリの型定義が含まれています。今回は NuGet パッケージ マネージャーを使用してこのような定義をプロジェクトに含めます。

Angular ライブラリ用の型定義を含めたら、この型定義を使用して、表示されているコンパイル エラーを解決できます。先ほどプロジェクトに追加した型情報を参照する必要があります。TypeScript コンパイラに型情報を参照するように指示する特殊なコメントを以下に示します。

/// <reference path="../Scripts/typings/angularjs/angular.d.ts" />

これで TypeScript コンパイラは、angular.d.ts という型定義ファイルで定義されているすべての型情報を解釈できるようになります。では、contactsApp 変数の型を変更します。contactsApp 変数に想定する型は、app.js の ng 名前空間で宣言されている IModule 型です。

declare var contactsApp: ng.IModule;

この宣言により、contactsApp の後ろにピリオドを入力すると必ず IntelliSense が表示されます。また、contactsApp オブジェクトで宣言されている API を誤入力したり誤用した場合、常に、TypeScript コンパイラのエラー報告が行われます。これでコンパイル エラーがなくなり、contactsApp オブジェクトの静的型情報を含められたことになります。

contactsController オブジェクトの残りのコードは、まだ型情報が不足しています。TypeScript コンパイラは、型の注釈を追加していない変数をすべて any 型と想定します。contactsApp.controller メソッドに渡す 2 つ目のパラメーターは関数で、この関数の最初の $scope パラメーターは ng.IScope 型です。そのため、次のように、ng.IScope 型を関数の宣言に含めます (contactData は依然として any 型と解釈されます)。

contactsApp.controller('ContactsController',
  function ContactsController($scope : ng.IScope, contactData) {
    $scope.sortOrder = 'last';
    $scope.hideMessage = "Hide Details";
    $scope.showMessage = "Show Details";
    $scope.contacts = contactData.getContacts();
    $scope.toggleShowDetails = function (contact) {
      contact.showDetails = !contact.showDetails;
    }
  });

これにより、また新しいコンパイル エラーが発生します。新しいエラーが発生する理由は、contactsController 関数内のコードで ng.IScope 型に含まれていないプロパティを操作しているためです。ng.IScope はインターフェイスなので、実際の $scope オブジェクトは IScope を実装するアプリケーション固有の型です。操作するプロパティはアプリケーション固有の型のメンバーです。TypeScript の静的型指定を利用するには、このアプリケーション固有の型を定義する必要があります。これを IContactsScope として次のように定義します。

interface IContactsScope extends ng.IScope {
  sortOrder: string;
  hideMessage: string;
  showMessage: string;
  contacts: any;
  toggleShowDetails: (contact: any) => boolean;
}

このインターフェイスを定義したら、関数の宣言で $scope 変数の型を変更するだけです。

function ContactsController($scope : IContactsScope, contactData) {

このように変更すると、アプリケーションをエラーなしでビルドして正常に実行することができます。このインターフェイスを追加するときに、確認しておく重要な概念がいくつかあります。この特定の型すべてで IContactsScope 型を実装するように宣言するために、他のコードを検索する必要はありません。TypeScript は、構造化された型指定 ("ダック タイピング") をサポートします。つまり、IContactsScope で宣言されているプロパティとメソッドを宣言するすべてのオブジェクトは、そのオブジェクトで IContactsScope インターフェイスを実装するように宣言しているかどうかに関わらず、IContactsScope インターフェイスを実装します。

IContactsScope の定義で、プレースホルダーとして any という TypeScript 型を使用しているのがわかります。contacts プロパティは連絡先の一覧を表し、Contact 型はまだ移行していません。any をプレースホルダーとして使用できます。TypeScript コンパイラは、このような値へのアクセスに型チェックを行いません。これは、アプリケーションの移行全体に便利なテクニックです。

any 型は、JavaScript から TypeScript にまだ移行していない任意の型を表します。これにより、毎回の繰り返しで解決が必要な TypeScript コンパイル エラーが減少し、移行がスムーズに進みます。また、any 型として宣言されている変数を検索し、やり残した作業を確かめることもできます。"any" は、その変数の型チェックを行わないことを TypeScript コンパイラに指示しています。その変数ではすべての型が想定されます。コンパイラは、その変数で利用できる API を開発者が認識しているものとします。"any" の使用がすべて不適切なわけではありません。JavaScript API がさまざまな型のオブジェクトで機能するように設計する場合などは、any 型を使用するのが効果的です。移行中に "any" をプレースホルダーとして使用することは、お勧めの形式の 1 つにすぎません。

最後に、次の toggleShowDetails の宣言で、TypeScript で関数の宣言を表す方法を示します。

toggleShowDetails: (contact: any) => boolean;

関数名は toggleShowDetails です。コロンの後に、パラメーター リストを指定します。この関数では、現在は any 型のパラメーターを 1 つ受け取ります。"contact" という名前は省略可能です。このパラメーターを使用して、他のプログラマに多くの情報を提供することができます。矢印で戻り値の型を指定します。この例では、boolean 型を指定しています。

次は、既に設定した IContactScope 定義の any 型に手を加えます。現在取り組んでいる型に関する情報を詳細に指定すると、TypeScript を使用してミスを回避することができます。この any 型を、連絡先の内容に関する定義に置き換えます。これを行うには、contact オブジェクトで利用できるプロパティを含む IContact 型を定義します (図 2 参照)。

図 2 contact オブジェクトにプロパティを含める

interface IContact {
  first: string;
  last: string;
  address: string;
  city: string;
  state: string;
  zipCode: number;
  cellPhone: number;
  homePhone: number;
  workPhone: number;
  showDetails: boolean
}

これで IContact インターフェイスを定義したので、次は IContactScope インターフェイスでこれを使用します。

interface IContactsScope extends ng.IScope {
  sortOrder: string;
  hideMessage: string;
  showMessage: string;
  contacts: IContact[];
  toggleShowDetails: (contact: IContact) => boolean;
}

contactsController 関数で定義している toggleShowDetails 関数の定義に型情報を追加する必要はありません。$scope 変数は IContactsScope 型なので、TypeScript コンパイラは、toggleShowDetails に割り当てられる関数は IContactScope で定義された関数のプロトタイプに必ず一致し、パラメーターは必ず IContact であることがわかります。

このバージョンの contactsController の生成後の JavaScript を見てみましょう (図 3 参照)。定義したすべてのインターフェイスの型が、生成後の JavaScript から削除されています。この型の注釈は、開発者用、およびスタティック分析ツール用に存在します。JavaScript はこのような注釈を必要としないため、生成後の JavaScript では削除されます。

図 3 TypeScript バージョンのコントローラーと生成後の JavaScript

/// reference path="../Scripts/typings/angularjs/angular.d.ts"
var contactsApp: ng.IModule;
interface IContact {
  first: string;
  last: string;
  address: string;
  city: string;
  state: string;
  zipCode: number;
  cellPhone: number;
  homePhone: number;
  workPhone: number;
  showDetails: boolean
}
interface IContactsScope extends ng.IScope {
  sortOrder: string;
  hideMessage: string;
  showMessage: string;
  contacts: IContact[];
  toggleShowDetails: (contact: IContact) => boolean;
}
contactsApp.controller('ContactsController',
  function ContactsController($scope : IContactsScope, contactData) {
    $scope.sortOrder = 'last';
    $scope.hideMessage = "Hide Details";
    $scope.showMessage = "Show Details";
    $scope.contacts = contactData.getContacts();
    $scope.toggleShowDetails = function (contact) {
      contact.showDetails = !contact.showDetails;
      return contact.showDetails;
    }
  });
// Generated JavaScript
/// reference path="../Scripts/typings/angularjs/angular.d.ts"
var contactsApp;
contactsApp.controller('ContactsController',
  function ContactsController($scope, contactData) {
  $scope.sortOrder = 'last';
  $scope.hideMessage = "Hide Details";
  $scope.showMessage = "Show Details";
  $scope.contacts = contactData.getContacts();
  $scope.toggleShowDetails = function (contact) {
    contact.showDetails = !contact.showDetails;
    return contact.showDetails;
  };
});
//# sourceMappingURL=contactsController.js.map

モジュールとクラスの定義を追加する

型の注釈をコードに追加すると、スタティック分析ツールから、コードで行ったかもしれないミスを探して報告できるようになります。これには、IntelliSense や lint のような分析から、コンパイル時のエラーや警告まですべての情報が含まれます。

JavaScript を上回る TypeScript の主なメリットは、型のスコープを指定する優れた構文です。TypeScript の module キーワードにより、型の定義をあるスコープ内に配置して、同じ名前を使用する他のモジュールの型との競合を避けることができます。

連絡先のサンプル アプリケーションはそれほど大きくありませんが、モジュール内に型の定義を配置して競合を避けるのは適切な考え方です。以下は、Rolodex というモジュール内に、定義した contactsController などの型を配置します。

module Rolodex {
  // Elided
}

このモジュール内の定義には export キーワードを追加していません。つまり、Rolodex モジュール内で定義された型は、Rolodex モジュール内でしか参照できません。このモジュール内で定義するインターフェイスに export キーワードを追加し、後で contactsData コードを移行するときにこの型を使用します。また、ContactsController を関数からクラスに変えるようにコードを変更します。このクラスでは、クラス自体を初期化するためのコンストラクターが必要ですが、他のパブリック メソッドは必要ありません (図 4 参照)。

図 4 ContactsController を関数からクラスに変える

export class ContactsController {
  constructor($scope: IContactsScope, contactData: any) {
    $scope.sortOrder = 'last';
    $scope.hideMessage = "Hide Details";
    $scope.showMessage = "Show Details";
    $scope.contacts = contactData.getContacts();
    $scope.toggleShowDetails = function (contact) {
      contact.showDetails = !contact.showDetails;
      return contact.showDetails;
    }
  }
}

この型を作成することで、contactsApp.controller への呼び出しが変わります。2 つ目のパラメーターは、前に定義した関数ではなく、class 型になります。コントローラー関数の最初のパラメーターはコントローラーの名前です。Angular は、コントローラーの名前をコンストラクターの関数にマップします。Angular では、ContactsController 型を参照する HTML ページのあらゆる場所で、次のように ContactsController クラスのコンストラクターを呼び出します。

contactsApp.controller('ContactsController', Rolodex.ContactsController);

これでコントローラーの型が完全に JavaScript から TypeScript に移行されます。新しいバージョンには、コントローラーで定義または使用されるすべての型の注釈が含まれます。TypeScript では、アプリケーションの他の部分を変更することなくこれを行うことができます。他のファイルには影響しません。TypeScript と JavaScript を混在させることはスムーズに行えるので、簡単に既存の JavaScript アプリケーションに TypeScript を追加することができます。TypeScript 型システムは、TypeScript と JavaScript 間の相互作用を容易にする、型インターフェイスと構造化された型指定を利用します。

ここからは、contactData.js に取り組みます (図 5 参照)。この関数は、Angular の factory メソッドを使用して、連絡先の一覧を含むオブジェクトを返します。コントローラーと同様、factory メソッドは、サービスを返す関数に名前 (contactData) をマップします。この規約は、コントローラーのコンストラクターで使用します。コンストラクターの 2 つ目のパラメーターは contactData という名前です。Angular はこのパラメーター名を使用して適切なファクトリにマップします。Angular フレームワークは規約に基づきます。

図 5 JavaScript バージョンの contactData サービス

'use strict';
contactsApp.factory('contactData', function () {
  var contacts = [
    {
      first: "Tom",
      last: "Riddle",
      address: "66 Shack St",
      city: "Little Hangleton",
      state: "Mississippi",
      zipCode: 54565,
      cellPhone: 6543654321,
      homePhone: 4532332133,
      workPhone: 6663420666
    },
    {
      first: "Antonin",
      last: "Dolohov",
      address: "28 Kaban Ln",
      city: "Gideon",
      state: "Arkensas",
      zipCode: 98767,
      cellPhone: 4443332222,
      homePhone: 5556667777,
      workPhone: 9897876765
    },
    {
      first: "Evan",
      last: "Rosier",
      address: "28 Dominion Ave",
      city: "Notting",
      state: "New Jersey",
      zipCode: 23432,
      cellPhone: 1232343456,
      homePhone: 4432215565,
      workPhone: 3454321234
    }
  ];
  return {
    getContacts: function () {
      return contacts;
    },
    addContact: function(contact){
      contacts.push(contact);
      return contacts;
    }
  };
})

繰り返しになりますが、最初の手順は拡張子を .js から .ts に単純に変更することです。これにより、クリーンにコンパイルされ、生成後の JavaScript はソースの TypeScript ファイルと正確に一致します。次に、同じ Rolodex モジュールに contactData.ts ファイルのコードを配置します。これにより、同じ論理パーティション内にあるアプリケーションのすべてのコードにスコープを設定します。

次に、contactData ファクトリをクラスに移行します。クラスを ContactDataServer 型として宣言します。これで、メソッドである 2 つのプロパティを含むオブジェクトを返す関数の代わりに、ContactDataServer オブジェクトのメンバーとして単純にメソッドを定義できるようになります。初期データは、ContactDataServer 型のオブジェクトのデータ メンバーになります。また、次に示すように、contactsApp.factory への呼び出しで ContactDataServer 型を使用する必要があります。

contactsApp.factory('contactsData', () => 
  new Rolodex.ContactDataServer());

2 つ目のパラメーターは、新しい ContactDataServer を返す関数です。このファクトリは、必要に応じてオブジェクトを作成します。ContactDataServer 型は Rolodex モジュールからエクスポートされないため、このバージョンをコンパイルおよび実行しようとすると、コンパイル エラーが発生します。ただし、これは contactsApp.factory への呼び出しで参照されます。これは TypeScript 型システムが寛容であることを示すもう 1 つの例で、移行タスクをより簡単にします。ContactDataServer クラスの宣言に export キーワードを追加することで、このエラーは簡単に解決します。

図 6 に最終バージョンを示します。addContact メソッドで、連絡先の配列と入力パラメーターの型情報を追加しています。型の注釈はオプションで、型の注釈がなくても有効な TypeScript ですが、TypeScript コードに必要なすべての型情報を追加することをお勧めします。これは、TypeScript システムでミスを回避でき、生産性が向上するためです。

図 6 TypeScript バージョンの ContactDataServer

/// reference path="../Scripts/typings/angularjs/angular.d.ts"
var contactsApp: ng.IModule;
module Rolodex {
  export class ContactDataServer {
    contacts: IContact[] = [
      {
        first: "Tom",
        last: "Riddle",
        address: "66 Shack St",
        city: "Little Hangleton",
        state: "Mississippi",
        zipCode: 54565,
        cellPhone: 6543654321,
        homePhone: 4532332133,
        workPhone: 6663420666,
        showDetails: true
      },
      {
        first: "Antonin",
        last: "Dolohov",
        address: "28 Kaban Ln",
        city: "Gideon",
        state: "Arkensas",
        zipCode: 98767,
        cellPhone: 4443332222,
        homePhone: 5556667777,
        workPhone: 9897876765,
        showDetails: true
      },
      {
        first: "Evan",
        last: "Rosier",
        address: "28 Dominion Ave",
        city: "Notting",
        state: "New Jersey",
        zipCode: 23432,
        cellPhone: 1232343456,
        homePhone: 4432215565,
        workPhone: 3454321234,
        showDetails: true
      }
    ];
    getContacts() {
      return this.contacts;
    }
    addContact(contact: IContact) {
      this.contacts.push(contact);
      return this.contacts;
    }
  }
}
contactsApp.factory('contactsData', () => 
  new Rolodex.ContactDataServer());

新しい ContactDataServer クラスを作成したので、最後にもう 1 つ変更をコントローラーに加えます。contactsController のコンストラクターの 2 つ目のパラメーターはデーター サーバーでした。次のパラメーターは ContactDataServer 型でなければならないと宣言することで、タイプセーフ性を向上しています。

constructor($scope: IContactsScope, contactData: ContactDataServer) {

JavaScript から TypeScript へのスムーズな移行

TypeScript には、ここで紹介したものよりも多くの機能が含まれています。TypeScript に取り組むにつれて、さまざまな機能が明らかになります。JavaScript に対する TypeScript の拡張機能を使用するほど、生産性が向上します。TypeScript の型の注釈は、JavaScript から TypeScript へのスムーズな移行を実現するために設計されています。最も重要なのは、TypeScript が JavaScript のスーパーセットであることです。つまり、有効な JavaScript は有効な TypeScript です。

また、TypeScript の型の注釈には形式的なものはほとんどありません。型の注釈は指定した場所でチェックされ、いたるところに追加する必要はありません。JavaScript から TypeScript に移行するときに、型の注釈が非常に役に立ちます。

最後に、TypeScript 型システムでは、構造化された型指定をサポートします。重要な型のインターフェイスを定義すると、TypeScript 型システムは、このようなメソッドやプロパティを含むあらゆるオブジェクトでそのインターフェイスをサポートすることを想定します。各クラス定義でインターフェイスのサポートを宣言する必要はありません。また、匿名オブジェクトも、この構造化された型指定の機能を使用してインターフェイスをサポートできます。

このような機能を組み合わせると、コードベースを JavaScript から TypeScript に移行する際のスムーズな経路が生み出されます。この移行経路に沿って進めれば、TypeScript のスタティック コード分析から多くのメリットが得られます。最終目標は、TypeScript の安全性をできる限り活用することです。その過程では、既存の JavaScript コードは、TypeScript の型の注釈を活用しない有効な TypeScript として機能します。これは、ほとんどストレスのないプロセスです。現在の JavaScript アプリケーションで TypeScript を使用しない理由はありません。

Bill Wagner は、現在では第2版も出版されている『Effective C#』(翔泳社、2009 年) および『More Effective C#』(翔泳社、2009 年) というベストセラーの著者です。また、Pearson Education informIT の『C# Async Fundamentals LiveLessons』(C# 同期の基礎のライブレッスン、英語) および『C# Puzzlers』(C# パズル、英語) という動画も作成しました。彼のブログは thebillwagner.com (英語) で、連絡先は bill.w.wagner@outlook.com (英語のみ) です。

この記事のレビューに協力してくれたマイクロソフト技術スタッフの Jonathan Turner に心より感謝いたします。
Jonathan Turner は、マイクロソフトで TypeScript チームのプログラム マネージャーを務めており、TypeScript 言語の共同設計者です。マイクロソフトに勤務する前は、Clang/LLVM および Chapel プログラミング言語に取り組んでいました。