わかったつもりになる「TypeScriptとその型システム」
TypeScriptのモチベーションとゴール
TypeScriptのメインゴール
- JavaScriptにオプションの型システムを提供すること
- 現行のJavaScriptエンジンに未来のJavaScript版の計画された機能を提供すること
TypeScript型システム
なぜJSに型を追加するのか?
- 型によりコードの品質と理解が向上するため
- 型はリファクタリング時の柔軟性を向上させるため
- 型は最良のドキュメント形式の一つであるため
TypeScriptの特徴
- TypeScriptはエントリーバリアを低く保つことに特に注意を払っている
- JavaScriptがそのままTypeScriptとして機能する
- 型は暗黙的にも明示的にもなり得る
- 暗黙的:TypeScriptはできるだけ多くの型情報を推論しようとする。
- 明示的:TypeScriptは安全に推論できる限り推論するが、アノテーション(型注釈)を使って型を明示的に指定することもできる。これにより、コンパイラを助けるだけでなく、次にコードを読む開発者(将来のあなたを含む)のためにドキュメントとして機能する。明示的な型指定では、コンパイラが見るものが自分が考えていたものと一致することを保証する。
- TypeScriptは型情報を推論し、コード開発中の生産性を最小限に抑えながら型安全性を提供する
型は構造的である
- ダックタイピングは言語構造の第一級市民である
ダックタイピング
オブジェクトの型をそのオブジェクトが持つメソッドやプロパティの集まりによって決定する方式。「もしそれがカモのように歩き、カモのように鳴くなら、それはカモである」という古い格言に由来する。つまり、オブジェクトが特定のインターフェースや契約を満たすメソッドやプロパティを持っていれば、そのオブジェクトの具体的な型に関係なく、特定の型として扱うことができる。
この概念は、特に動的型付け言語やいくつかの静的型付け言語において、柔軟で直感的なコーディングスタイルを可能にする。
TypeScriptの文脈では、ダックタイピングは構造的型付けの一形態として現れる。
TypeScriptでは、型はその構造(つまり、持っているメンバー)によって決定される。例えば、ある関数が特定のプロパティを持つオブジェクトを引数に取る場合、そのプロパティを持つ任意のオブジェクトをその関数に渡すことができる。これにより、TypeScriptはJavaScriptの柔軟性を保ちつつ、型安全性を提供する。
詳細
いくつかのプログラミング言語では、静的型付けが「名目的(nominally typed)」。これは、型がコードの構造ではなく名前に基づいていることを意味する。その結果、コードが問題なく動作することが分かっていても、言語のルールに従って、不必要にコードをコピーしたり、調整したりする必要がある。
(例えば、C#においては、異なる型間でのデータの移動を自動化する「AutoMapper」のようなツールが重要になる。)
しかし、TypeScriptでは「構造的(structural)」型付けが採用されている。これは、オブジェクトが期待されるプロパティ(この場合はxとy)を持っていれば、そのオブジェクトの具体的な型は問われない、というもの。これを「ダックタイピング」と呼ぶ。
例えば、iTakePoint2Dという関数がある場合、この関数はxとyプロパティを持つ任意のオブジェクトを受け入れることができる。
具体的な例として、Point2Dインターフェースと完全に一致するオブジェクト(point2D)、Point2Dの条件を満たしつつ追加のプロパティ(z)を持つオブジェクト(point3D)を受け入れることができる。しかし、必要なプロパティ(この場合はy)が足りないオブジェクトを渡すと、エラーが起きる。
interface Point2D {
x: number;
y: number;
}
interface Point3D {
x: number;
y: number;
z: number;
}
var point2D: Point2D = { x: 0, y: 10 }
var point3D: Point3D = { x: 0, y: 10, z: 20 }
function iTakePoint2D(point: Point2D) { /* do something */ }
iTakePoint2D(point2D); // exact match okay
iTakePoint2D(point3D); // extra information okay
iTakePoint2D({ x: 0 }); // Error: missing information `y`
型エラーはJavaScriptの生成を妨げない
- コンパイルエラーがあっても、TypeScriptは可能な限り有効なJavaScriptを生成する
型はアンビエント(周囲の環境)である
TypeScriptは特別な宣言を通じて、既存のJavaScriptライブラリをTypeScriptで安全かつ容易に使用できるようにする。これを「アンビエント型」という。
詳細
TypeScriptがどのようにして既存のJavaScriptコードやライブラリと連携できるように設計されているかを説明する。このアプローチにより、TypeScriptはJavaScriptのエコシステムの豊富なリソースを活用しつつ、型の安全性と開発者の生産性を向上させることができる。
アンビエント型とは、TypeScriptでの宣言の概念を指し、既存のJavaScriptコードやライブラリがTypeScriptの型システム内でどのように動作するかを定義するものである。これにより、TypeScriptを使用している開発者は、任意のJavaScriptライブラリをTypeScriptの型チェックの恩恵を受けながら使用できる。
ここでの「アンビエント」という用語は、周囲の環境やコンテキストを指す言葉で、この文脈では既存のJavaScriptコードやライブラリをTypeScriptのコード内で「周囲の環境」として取り込み、それらをTypeScriptの型システムで認識できるようにすることを意味している。
宣言ファイル(通常.d.ts
ファイルとして拡張子がつけられる)を通じて、TypeScriptは既存のJavaScriptライブラリのAPIを型情報として認識できるようになる。これにより、開発者はライブラリを使いながらも、TypeScriptの型安全性とコード補完などの利点を享受できる。
たとえば、jQueryのようなJavaScriptライブラリをTypeScriptプロジェクトで使いたい場合、declare var $: any;
のように記述することで、$
変数が任意の型を持つと宣言できる。より詳細な型定義を提供することで、$(selector: string): any;
のように特定のAPI呼び出しに対して型安全性を向上させることが可能になる。
未来のJavaScriptを今に持ってくる
TypeScriptは現行のJavaScriptエンジンで計画されているES6の機能を提供する
詳細
TypeScriptが現在のJavaScriptエンジンで将来のECMAScriptのバージョン(具体的にはES6やそれ以降)で計画されている機能を利用可能にするという概念。
つまり、TypeScriptを使用することで、現行のJavaScriptエンジンが直接サポートしていない最新のJavaScriptの機能やシンタックスをコードに組み込むことができ、それらのコードはTypeScriptコンパイラによって現行のJavaScriptエンジンでも動作するようにトランスパイル(変換)されるということ。
ES6(ECMAScript 2015とも呼ばれる)は、JavaScriptの言語仕様の一つであり、クラス、モジュール、アロー関数、プロミスなど多くの新機能を導入した。
しかし、すべてのブラウザやJavaScript環境がこれらの新機能をサポートしているわけではない。
TypeScriptを使用すると、これらの新しい機能を使って開発を行いながらも、より広範な互換性を持つJavaScriptにコンパイルすることができるので、開発者は新しいシンタックスや機能を安心して利用することができる。
例えば、TypeScriptではES6のクラスやアロー関数などの構文を使用してコーディングすることができ、TypeScriptコンパイラはそれを現行のJavaScript環境でも動作するコードに変換する。
このようにして、TypeScriptは未来のJavaScriptの機能を「今」、つまり現在の開発環境で利用できるように橋渡しをしている。
Reference
TypeScriptにおける型システム
- プログラム内のさまざまな値や変数に「型」を割り当てる決まりを指し、データの性質や扱い方を決定するものである。
- TypeScriptが採用しているのは「構造的型付け」である。
- 構造的型付けでは、型の名前ではなくその「構造」に着目して型の区別や互換性を判定する。
- 型が持つプロパティやメソッドの構造が同一であれば、異なる名前を持つ型同士でも互換性があると見なされる。
Reference
型空間と値空間
TypeScriptには、記号(シンボル)が2つの空間に存在する可能性がある。シンボルが型空間にあるのか、値空間にあるのかを判断するのは、そのシンボルが使用されるコンテキストから。型空間の構造体と値空間の構造体は見た目が同じであることが多いため、特に混乱しやすい。
-
型空間(Type Space)
-
interface
、type
、型エイリアスなどで導入される記号 - 静的な型を表す
-
-
値空間(Value Space)
-
const
、let
、class
などで導入される記号 - 実行時の値を表す
-
リテラル型などは、型宣言後のシンボルが型空間にあり、const
やlet
宣言で導入されるシンボルは値である。
同じ名前の記号でも、それが型空間にあるか値空間にあるかで意味が異なる。
例えば:
interface Cylinder {
radius: number;
height: number;
}
const Cylinder = (radius: number, height: number) => ({radius, height});
ここでは、interface Cylinder
は型空間に記号を導入し、const Cylinder
は値空間に同じ名前の記号を導入している。しかし、これらは全くの別物である。
状況によっては、Cylinder
と書いた時に、型なのか値なのかが分かりづらく、エラーの原因になる可能性がある。
function calculateVolume(shape: unknown) {
if (shape instanceof Cylinder) { // Cylinderは値空間の関数を参照する
shape.radius // エラー: shapeには'radius'プロパティがない
}
}
このように、型空間と値空間の同じ記号が混在すると分かりづらくなる。
型空間と値空間の直感を養う最良の方法の一つは、TypeScript Playgroundを使うこと。ここでは、TypeScriptソースコードに対して生成されたJavaScriptを見ることができる。
型はコンパイル中に消去されるため、シンボルが消えてしまう場合、それは型空間にあったということである。
TypeScriptプレイグラウンドを使って、型が消えるか(型空間)、残るか(値空間)を確認するのが良い。
TypeScriptの文は、型宣言(:
)やアサーション(as
)後のシンボルが型空間に属し、代入(=
)後のすべてが値空間に属する。例えば、「interface Person」と「const jane: Person」では、前者が型空間、後者が値空間に属する。
型空間と値空間が入り混じる場面もある。例えば、関数宣言では引数リストが型空間、関数本体が値空間になる。
function email(to: Person, subject: string, body: string): Response {
// ----- -- -------- ----- 値空間
// ------- ------ ------ --------- 型空間
// ...
}
同じ構文でも、型空間と値空間で意味が異なる場合がある。
-
typeof
: 型空間では値の型を返す。値空間ではJavaScriptのtypeof
演算子。 -
[]
: 型空間ではインデックスアクセスで型取得、値空間では配列アクセス。 -
this
: 型空間ではポリモーフィックthisの型、値空間ではJavaScriptのthis
。 -
&
と|
: 型空間では交差型と共用体型、値空間ではビット演算子。
型空間と値空間を意識することで、コードの曖昧さを取り除き、TypeScriptを上手に活用できるようになる。
Reference
補足
ポリモーフィックthis by Claude
はい、ポリモーフィックthisの型について説明します。
一般的に、JavaScriptのthisは実行時の値が決まり、その値はクラスのインスタンスである場合とそうでない場合があります。TypeScriptではこの両方の場合に対応するため、ポリモーフィックthisという特別な型が用意されています。
ポリモーフィックthisの型は、メソッドの中でthisを使用する際に、thisの型がそのメソッドが属するクラスの型になるよう保証する機能です。つまり、クラスのサブクラスでもthisの型が正しくなります。
例えば、以下のようなコードを考えてみましょう:
class BasicCircle {
radius = 1;
scale(factor: number) {
this.radius *= factor; // thisの型はBasicCircleのインスタンス
}
}
let c = new BasicCircle();
c.scale(2); // radius は 2
ここではscale
メソッドの中で、thisの型がBasicCircle
のインスタンスとして扱われています。
さらに、サブクラスを作った場合でも適切にthisの型が決まります:
class Circle extends BasicCircle {
area() {
return Math.PI * this.radius ** 2; // thisの型はCircleのインスタンス
}
}
let c = new Circle();
c.scale(2); // radius は 2
console.log(c.area()); // 12.566...
Circle
クラスのarea
メソッドにおいて、thisの型がCircle
のインスタンスと推論されています。
このポリモーフィックthisの仕組みにより、サブクラスでもthisが適切に処理され、安全でDRYなコードを書くことができます。メソッドチェーンのように、this経由でメソッドを連鎖させる場合に特に役立ちます。
型アサーションより型アノテーションを好む
要点
型アノテーションを優先し、型アサーションや非nullアサーションは、TypeScriptが持っていない型に関する情報を持っている場合にのみ使う。また、型アサーションを使用する際には、その妥当性についての説明をコメントに含めるのが良い。
詳細
TypeScriptで変数に型を指定する方法には、主に型アノテーションと型アサーションの二つあるが、一般的に型アノテーションの使用が推奨される。これは、型アノテーションが変数の型を明示的に宣言し、値が指定された型に適合しているかどうかをコンパイラが検証するためである。これにより、型の不一致によるエラーを早期に発見し、修正できる。
interface Person { name: string }
// 型アノテーションを使用する例
const alice: Person = { name: 'Alice' }
一方、型アサーションは、開発者がコンパイラの型推論を上書きし、変数の型が何であるかを「強制」する方法。これは、TypeScriptがコンテキストから得られる情報を完全には理解できない場合に便利だが、誤用すると実際には型の不一致が存在していてもエラーを無視することになるため、慎重に使用するべき。
// 型アサーションを使用する例
const bob = { name: 'Bob' } as Person
特に、DOMの要素のようにTypeScriptが自動的に正確な型を推論できない場合や、null
が含まれないことが確実な場合に、型アサーションや非nullアサーション(!
)を使うことがある。しかし、これらの使用は特別な状況でのみに限定し、使用する際にはその理由をコメントで説明することが良い。
// 非null アサーションの使用例
const el: HTMLElement = document.getElementById('foo')!
また、アロー関数の戻り値に型アノテーションを使用することで、関数全体を通じて型安全性を保証することができる。これは特に、関数の戻り値が特定の型であることを保証したい場合に有効である。
// アロー関数の戻り値に型アノテーションを使用する例
const people = ['alice', 'bob', 'jan'].map(
(name): Person => ({name})
)
最後に、as const
を使用することでリテラル値の型をより厳密にすることができる。これは型アサーションとは異なり、変数が持つリテラル値を「定数」として扱い、TypeScriptによる型推論をより精密化するもの。これにより、コードの型安全性をさらに強化できる。
// as constを使用する例
const status = 'loading' as const
総じて、型アノテーションはコードの型安全性を強化し、エラーを事前に防ぐための推奨される方法である。型アサーションは必要な場合に限り、慎重に使用し、その妥当性を常にコメントで説明することが重要である。
補足
「DOMの要素のようにTypeScriptが自動的に正確な型を推論できない場合」の例byGPT4
TypeScriptがDOM要素のような場合に自動的に正確な型を推論できない主な理由は、実行時の状況に依存するからです。TypeScriptは静的型付け言語であり、コンパイル時に型の検査を行います。しかし、DOM要素に関しては、その型が実行時によって変わる可能性があり、HTMLの構造やJavaScriptによる動的な変更によって、異なる型の要素が同じIDやクラス名で参照されることがあります。
例えば、document.getElementById
やdocument.querySelector
のようなメソッドは、特定のIDやセレクタにマッチするDOM要素を返しますが、その要素が具体的にどのような型(HTMLDivElement
、HTMLButtonElement
など)かは、HTMLのマークアップに依存します。TypeScriptはこのHTMLマークアップを解析しないため、これらのメソッドから返される要素の具体的な型を推論することができません。
<button id="myButton">Click me</button>
const button = document.getElementById('myButton');
上記のコードでは、button
変数がHTMLButtonElement
型であることは明らかですが、TypeScriptはgetElementById
がHTMLElement
型を返すことしか知りません。そのため、HTMLButtonElement
に固有のプロパティやメソッド(例えばdisabled
プロパティ)を使用する場合、開発者は型アサーションを使用してTypeScriptに型情報を「教える」必要があります。
const button = document.getElementById('myButton') as HTMLButtonElement;
button.disabled = true; // 型アサーションにより、`disabled` プロパティの使用が可能に
このように、DOM操作では実行時の状況に依存するため、TypeScriptが自動で正確な型を推論することは困難です。その結果、型安全性を確保するために型アサーションが必要になるケースがあります。
Reference
型の互換性に関する2つの概念(共変性、反変性)
共変性(Covariance)と反変性(Contravariance)は、型の継承や代入の際の関係性を表現する。
共変性(Covariance)
ある型が別の型のサブタイプである場合、その型のコンテナや関数の戻り値も同様にサブタイプ関係を保持すること。
例えば、Dog
がAnimal
のサブタイプである場合、Dog[]
はAnimal[]
のサブタイプになる。
この性質は、型の代入が「同じ方向」で行われることを示す。
class Animal {
name: string
constructor(name: string) {
this.name = name
}
}
class Dog extends Animal {
breed: string
constructor(name: string, breed: string) {
super(name)
this.breed = breed
}
}
let animals: Animal[] = []
let dogs: Dog[] = []
// Covariance allows assigning subtype (Dog) array to supertype (Animal) array
animals = dogs
dogs = animals // Invalid: Type 'Animal[]' is not assignable to type 'Dog[]'
反変性(Contravariance)
ある型の関数の引数が別の型のサブタイプである場合に、その関数自体が逆のサブタイプ関係を持つこと。
例えば、Animal
を引数に取る関数があるとき、Dog
を引数に取る関数はAnimal
を引数に取る関数のサブタイプになる。(→Animal型の引数受け取ってもDog型のプロパティbreedは存在しないですもんね)
これは、関数の引数に関しては「逆方向」のサブタイプ関係が成立することを意味する。
type Feed<in T> = (animal: T) => void
let feedAnimal: Feed<Animal> = (animal: Animal) => {
console.log(`Animal name: ${animal.name}`)
};
let feedDog: Feed<Dog> = (dog: Dog) => {
console.log(`Dog name: ${dog.name}, Breed: ${dog.breed}`)
}
// Contravariance allows assigning supertype (Animal) callback to subtype (Dog) callback
feedDog = feedAnimal
feedAnimal = feedDog // Invalid: Type 'Feed<Dog>' is not assignable to type 'Feed<Animal>'
Reference
TypeScript 4.7.0以降では、型パラメーターの共変性や反変性を明示的に指定するためのin
とout
キーワードが導入された→型パラメーターの変性をより細かく制御できるようになった。