HTML5Experts.jp

あなたはどこまで潜れるか?TypeScriptの世界にディープ・ダイブ!

こんにちは、編集長の白石です。

この記事は、9月24日に開催されたHTML5 Conference 2017に登壇したエキスパートに、お話されたセッションのトピックを中心に語っていただこうとういものです。セッションの内容をより深く理解する手助けになるだけでなく、本記事単体でも面白く読んでいただけることを目指しています。

今回お話を伺ったのは、株式会社サイバーエージェントの青野健利さんです。

青野さんのセッション「Deep dive into TypeScript」に関するスライド資料は、こちらで公開されています。

なお本稿のサンプルコードは、ほとんどが上記スライドから引用したものになります(一部説明を簡略化するため、青野さんの許可を得て筆者が書き換えた部分もあります)。

筆者からのお詫び: 今回のインタビュー、および対象セッションは大変内容が濃く、記事にすると長大になりすぎる&スライド資料が大変参考になるため、「型」の話に絞った記事として再構成させていただきました。ご容赦ください。

TypeScript、基本の基本

白石: 青野さん、今日は取材をお受けいただきありがとうございます。まずは簡単に自己紹介をお願いします。

青野: はい、私は2010年ごろからWeb系の仕事に従事していまして、2011年からサイバーエージェントに所属しています。普段は広告系の部署で、チャットボットのツールを作ったりしています。

白石: 今回はTypeScriptについてお聞きできるということですが、仕事でもTypeScriptを使ってらっしゃるのですか?

青野: はい。というか、TypeScriptしか使っていません(笑)。

白石: なるほど(笑)。ではまずはTypeScriptについて、簡単に概要を教えていただけますか?

青野: TypeScriptとは、Microsoftが作ったJavaScriptのスーパーセットですね。特徴は静的型付けの言語だということ、あとはECMAScriptの新しい仕様を先取りしているというところですね。AltJSと呼ばれる言語の中では、頭一つ抜きん出て利用されている言語じゃないかと思います。

白石: 今回は「Deep Dive」というセッションタイトルでもあったとおり、かなり深いところまでお話されてそうですよね。私も普段TypeScriptは使っていますが、どこまで一緒に潜れるか、不安です(笑)。まずは簡単なところから教えてください。

青野: はい、まずは型についてお話しましょう。TypeScriptにおける型の最も大きな特徴は、コンパイル時にのみ評価されるということですね。コンパイルされた結果のJavaScriptは型を持たない言語ですから、当然型の情報は含まれていません。

また、TypeScriptは型を書かなくてもよい言語です。型推論があるので、変数の型が代入文から自動的に推論されます。また、anyという型があって、この型の変数に対しては、どんなプロパティアクセスやメソッド呼び出しを行ってもコンパイルエラーになりません。

これらの特徴は、すべてJavaScriptとの統合を真面目に考えているからこそです。既存のJavaScript資産を活かせるよう、そしてJavaScriptコードとの相互運用性を高く保てるよう、よく考えられています。

白石: 既存のJavaScript資産をTypeScriptでも使えるよう、型定義ファイルがコミュニティの手で作られていますよね。

青野: Definitely Typedですね。品質に多少の問題があるので賛否両論ではありますが、コミュニティの力が発揮された素晴らしい成果だと思います。

Structual Subtyping

白石: では、だんだん詳しい話題に踏み込みましょうか。

青野: はい、まず、TypeScriptの型システムは、Structual Subtypingを採用しているのが特徴です。これは、型の同一性を型の名前ではなく、型の構造だけで判断するというものです。

例えば、xyというメンバーを持つPoint2Dというインターフェースと、それを引数として受け取るメソッドがあったとします。

そのメソッドに渡すオブジェクトは、xyというメンバーを持ってさえいれば、Point2Dインターフェースを実装している必要はありません。

interface Point2D {
 x: number;
 y: number;
}
const point = {
 x: 0,
 y: 0
};
function acceptPoint2D(point: Point2D) {}
acceptPoint2D(point);

白石: ダック・タイピングの静的型付け版、みたいな感じですよね。

今、インターフェースの話が出ましたが、TypeScriptにおけるユーザー定義型についても教えてください。

青野: TypeScriptでは、型を定義するのにクラス、インターフェース、Enum(列挙型)のような機能があります。

ECMAScriptでも、6からはクラスを作れるようになりましたが、TypeScriptではより多くの機能を持ったクラスを使うことができます。例えばアクセス修飾子を付けて、プロパティやメソッドの公開範囲を限定することができたり、readonlyキーワードを用いてイミュータブルなプロパティを作ることもできます。

インターフェースはクラスが実装すべき規約を表すための機能です。implementsというキーワードでクラスに付与すると、インターフェースが持つメソッドやプロパティを実装しないとコンパイルできなくなります。

TypeScriptのインターフェースはコンパイル時にのみ用いられます。出力されるJavaScriptコードには、インターフェースの情報は残りません。

// クラスの例。OtherClassを継承し、OtherInterfaceを実装している
// アクセス修飾子、staticプロパティ、readonlyプロパティなど、TypeScript固有の機能が多数ある
class MyClass extends OtherClass implements OtherInterface {
  public constructor() { super(); }
  protected methodFoo() { }
  private property: number = 1;
  public static methodFoo() { }
  private static property = 1;
  public get getter() {}
  public set setter(value: any) {}
  public get set prop() {}
  private readonly immutableProp: string = 'a';
}

白石: typeというキーワードで型が作れますよね。インターフェースとの使い分けがいまいちよくわからないんですが。

// 以下のように型を作ることもできる
type MyType = {
  a: number;
};

青野: typeキーワードは、基本的には他の型のエイリアスを作るだけなんです。同様のことをインターフェースで実現できる場合は、インターフェースの利用が推奨されています。例えばtslintで、typeの代わりにインターフェースを使うように強制することもできますね。

ちょっと変わった型…文字列リテラル型、Index Type

青野: ただ、文字列リテラル型(String Literal Type)を定義する場合はtypeキーワードを使う必要があります。

白石: だんだん深い話になってきましたね。文字列リテラル型というのは、型に文字列リテラルを指定できるっていうTypeScript独自の機能ですね。

// 'hello'か'world'しか代入できないHelloWorld型を定義
type HelloWorld = 'hello' | 'world';

// 以下の文はコンパイルエラー const s: HelloWorld = 'good morning';

他にも、Index Typeというのもあります。この機能を使うと、プロパティが動的に変化するオブジェクトの場合も、コンパイラで可能な限り型チェックできるようになります。以下の例では、キーは文字列、その値も文字列となる動的な型を定義しています。

// Index Type
type StringMap = { [key: string]: string };

白石: プロパティ名のところに[name: type]のように書くのがポイントですね。

型の合成(Intersection Type)と型の結合(Union Type)

青野: ほかにtypeを使用する必要があるのは、型の合成を行う場合です。

白石: 型の合成?

青野: はい、Intersection Typeと言って、型の積集合を作る機能です。もっと簡単に言えば、二つの型を混ぜ合わせる機能ですね。使用方法は簡単で、二つ以上の型を’&’でつなぐだけです。

// Insersection Type
type A = { x: number };
type B = { y: number };
// Point2Dはxとyをメンバーに持つ
type Point2D = A & B;

クラス、インターフェース、type宣言で作成した型など、様々な型を混ぜ合わせて新しい型を作ることが可能です。

白石: こんな機能あったんですね、知りませんでした。

青野: 他にはUnion Typeといって、どちらかの型を排他的に持つ型を作ることもできます。こちらは、二つ以上の型を’|’でつなぐことで作ることができます。

// Union Type
type UnionType = string | number;

白石: Union Typeは使ったことあります。いろんな型を受け取る関数を作るときとかに便利ですよね。

総称型(ジェネリクス)

青野: ジェネリクスは「総称型」とも呼ばれ、型をパラメータ化できるというものです。

総称型は、パラメータ化された型を外から与えられて、初めて実際の型が決まります。

// <T>が型パラメータを表す
class MyArray<T> {
  private arr: T[];
  public push(value: T) { this.arr.push(value); }
}
const numArr = new MyArray<number>();

上の例で言うと「T」がパラメータ化された型ですね。Tは、MyArray型を実際に使っている new MyArray<number>()のところで、number型を与えられています。

また、パラメータ化される型に「ある型を親として持っていなくてはならない」などの制約を設けることもできます。その場合はextendsキーワードを用いて、以下のように型パラメータを記述します。

function fn<T extends {x: number}>(value: T) {
  // valueはプロパティxを確実に持っている
  return value.x + 1;
}

白石: ジェネリクスはTypeScript固有の機能というわけではないですよね。C++やJava、C#など、静的型付けの言語はジェネリクスを備えるものも多い。

Type Guard

青野: Type Guardは、型をインテリジェントに絞り込む機能です。例えば以下のようなコードでは、instanceoftypeofで判定しているif文のブロック内では、変数をその型だと見なして、キャストせずに利用することができます。

function fn(
  x: Element | number | boolean
) {
  if (x instanceof Element) {
    // console.log((<Element>x).tagName);
    console.log(x.tagName);
  } else if (typeof x === 'boolean') {
    console.log(x);
  } else {
    // console.log((<number>x).toFixed(0));
    console.log(x.toFixed(0));
  }
}

白石: あ、これは知ってますし、よく使いますね。最初この機能を知った時、最近のコンパイラは頭がいいなあ、と感動しました。

青野: さらにType Guardは、開発者が拡張することもできるんですよ。以下のような関数を用意しておくと、if文の条件式でこの関数を用いた時、そのブロック内では変数がstring型として扱われます。

function isString(x: any): x is string {
  return typeof x === 'string';
}
let n = 'a';
if (isString(n)) {
  // キャストせずにstring型として扱える
  console.log(n.toUpperCase());
}

白石: 戻り値の型に<仮引数名> is <型名>と指定するんですね。Type Guardを自作できるとは知りませんでした。

never型

青野: neverという型もあります。neverは決して到達できない、何も代入することのできない型を表します。例えば、必ずエラーをthrowする関数の戻り値型をnever型にしたり、到達することのないコードブロックの変数型がneverになったりします。

// 必ずエラーをthrowする関数
function test1(): never {
  throw new Error('error');
}
function test2(value: string | number) {
  if (typeof value === 'string') {
  } else if (typeof value === 'number') {
  } else {
    // 到達しないブロック
    // ここではvalueはnever型として扱われる
    value
  }
}

白石: なんとなく分かる気はしますが、何の役に立つんですか?これ。

青野: 確かにわかりづらいですよね。voidとの違いがわからないという話もよく出ます。

ただ、以下の例を見るとわかりやすいんじゃないかと思います。例えば後者の特徴(到達することのないコードブロック内での変数型がneverになる)をうまく使うと、より型安全なコードを書くのに役立ったりします。

enum Test { KEY_1 = 1, KEY_2, KEY_3 }
function test(m: Test) {
  switch (m) {
    case Test.KEY_1:
      return 'key1';
    case Test.KEY_2:
      return 'key2';
    default;
      // Test.KEY_3のケースが存在するので、mはnever型ではない
      const check: never = m;
  }
}

白石: なるほど、到達しないはずのコード位置で変数をneverとして扱うコードを書いておけば、コンパイラがコードの到達可能性をチェックしてくれるということですね。

Mapped Type

青野: Mapped Typeは、TypeScript 2.1から入った、比較的新しくてなかなか面白い機能です。この機能を使うと、他の型を元にして、型を動的に定義することができるんです。

白石: だいぶ深い話になってきたな…。型を動的に定義、とはどういうことですか?

青野: 例えば、「あるインターフェースがあって、そのプロパティすべてをreadonlyにした新しい型を作りたい」といった場合を考えてください。

白石: ああ、そういうのはありそうですね。でも、元のインターフェースを継承してもできないし、どうすればいいんですか?

青野: そういう場合TypeScriptでどうやるかというと、まず元のインターフェースのプロパティ名を全て含む型を作るんです。

keyofというキーワードを使うと、型のプロパティ名を全て含む文字列リテラル型を取得できます。

interface MyInterface {
 a: string;
 b: number;
 c: boolean;
}
type AKeys = keyof MyInterface;
// AKeys = 'a' | 'b' | 'c'

青野: そして、以下のように書くことで、全てのプロパティをreadonlyにした型を作ることができます。

・プロパティ名に[P in AKeys]と指定していること ・プロパティの型指定を行うところにMyInterface[P]を指定していること ・全てのプロパティの先頭にreadonlyが付与されること

がポイントになります。

type MyInterfaceClone = {
  readonly [P in AKeys]: MyInterface[P]
};
// MyInterfaceClone = {
//  readonly a: string;
//  readonly b: number;
//  readonly c: boolean;
// }

白石: 元の型のプロパティをループしながら、新しい型を作るような感覚ですね。コンパイル時に、動的な処理を行って型を作っていっているような感覚ですね。

青野: こうしたテクニックを使うと、全部のプロパティをPromiseでラップしたり、null許容型にしたりした新しい型を作るのも容易に行なえます。

白石: いやー、TypeScriptは型の定義方法がとても柔軟ですね…。しかし、だいぶ深いところまできた気がしますね。ぼく普段TypeScript使ってるのに、知らないこともたくさんありました。

青野: いや、実はこのスライドで紹介してるのは、私が重要だと判断したところだけで、TypeScriptにはまだまだ機能があります(笑)。

白石: ええっ、もう頭がいっぱいいっぱいなんですが…。

Variance

白石: もうちょっと頑張ります。ここからは、TypeScriptに今後搭載される予定の機能とかロードマップ、議論されている話について伺っていきたいと思います。

青野: はい、では一つ目の話題は「Variance」です。これは、「ある型と別の型が相互代入可能か」っていう関係性についてのお話です。

例えば、Baseというクラスと、それを継承しているクラスがあったとして、それらの配列と、それらを仮引数にとる関数について、それぞれ相互代入可能かを考えてみましょう。

class Base {
 value = 1;
}
class Derived extends Base {
 otherValue = 2;
}
class Another extends Base {
 anotherValue = 3;
}

以下は配列の場合です。親クラス型の配列(baseAttr)に、子クラス型の配列(derivedArr)は代入できます。これを共変の関係といいます。

親クラス型の配列変数に、子クラスの配列を代入するというのはよくあるシチュエーションです。配列が共変であるというのは、実用上の利便性が高いんです。

(筆者注: Wikipediaの「共変性と反変性」の項目も参照のこと)

declare let baseArr: Base[];
declare let derivedArr: Derived[];
// Covariant 共変
baseArr = derivedArr;
// Contravariant 反変 Error
// TypeScriptでは認められていない
derivedArr = baseArr;

しかし、これらの型を仮引数に取る関数について考えてみましょう。この場合、どちらの方向の代入も成り立ってしまうのです。これを双変の関係といいます。

declare let processDerived: (derived: Derived) => void;
declare let processBase: (base: Base) => void;

// Bivariant 双変 processDerived = processBase; processBase = processDerived;

// Runtime Error. processBase(new Another())

つまりTypeScriptは、配列については共変、関数については双変としているわけですが、これは実は型システムに矛盾を生じます(※)。 こうした矛盾はあえて受け入れたものであり、その理由は「配列の共変性」による利便性、実用性を重んじてのことでした(※)。

しかし実は、関数についての双変性をやめ、C#のように共変/反変をin/outパラメータで指定できるようにしてはどうか…という議論が持ち上がっています

白石: …む、むずかしい…

※ここは、議論が詳細過ぎるため編集上割愛しました。詳しくは青野さんのスライドをご覧いただくくか、なぜ TypeScript の型システムが健全性を諦めているかというna-o-ysさんの記事が参考になると思います。

Unique Symbol型

これからの展望としては、Unique Symbol型ですね。これはconstな変数やreadonlyなプロパティを、クラスやインターフェース、typeのプロパティ名に使うことができます。

const x = "literal name";
const y = 1;
export interface A {
}

白石: Computed property namesを型宣言時に使えるような感じですね。

青野: ちなみに、ECMAScriptにprotocolという機能を追加するって提案があります。Unique Symbol型とはあまり関連はありませんが、プロパティ名にシンボルを指定するというところから思い出しました。

白石: それはどういうものですか?

青野: インターフェースに近くて、implementsするクラスに実装を強要するものです。複数のプロトコルをimplementsすることもできます。

白石: 型の概念が薄いJavaScriptにも、ついにインターフェース相当のものが!でも、それならinterfaceってキーワード使えばいいのに…。

青野: そうですねー。ただ、目的は似通っていますが、プロトコルは「シンボルの集合」という形で表現されたり、デフォルト実装を提供できたりと、結構違いはあります。

青野さんがその後書いてらっしゃったブログ記事からの引用ですが、プロトコルは以下のように宣言/実装するそうです。

protocol ProtocolName {
  // 実装が必要なシンボルを宣言
  thisMustBeImplemented;
}

class ClassName implements ProtocolName { ProtocolName.thisMustBeImplemented { ... } }

可変長型

青野: ほかには、可変長型をサポートすることも予定されています。可変長型というのは、言ってみれば配列要素の型をまとめて保持する型です。

...Tのように記述することで可変長型を表すことができます。

function makeTuple<...T>(...ts: ...T): ...T {
  return ts;
}
const ts = makeTuple('a', 1, {x: 1});
// ...T = [string, number, {x: number}]

可変長型のサポートにより、可変長引数を取る関数で引数の型を利用できたり、可変長の型パラメータを実装できます。

また、可変長型はタプルとも互換性があります。

白石: こんなアイデアまであるんですね…ぼくなんか、TypeScriptでタプル使えるの今まで知りませんでした(笑)。セッション内容の、型に関するお話だけでも、すごいボリュームになりました。。だいたいこんなところでしょうか…?

青野: そうですね、他にはStructual Subtyping以外の型システム、例えばNominal Typingなどを許容しよう(※)という話もありますが…まだ実装の同意は取れてない状態ですね。

白石: もうお腹いっぱいです(笑)。大変詳しい話を聞かせていただき、今日はありがとうございました!

(撮影:刑部友康 写真提供:html5j HTML5 Conference 2017事務局)