TL;DR
- TypeScript において、「シンボル」には「値空間に存在するものと型空間に存在するものの2つがある。両者の世界は断絶している。
- Typescriptを実行するには、コードをJavascriptに変換(トランスパイル)して実行する必要があるが、その際に
type
やinterface
といったTypescript特有の情報は、すべて消え去る。
Typescriptのコードは「値空間に存在するもの」と「型空間に存在するもの」の2つがある
// 型空間(Javascriptへのトランスパイル時にすべて失われる)
// interfaceは、シンボルを型空間に宣言する(=型を宣言する)
interface Cylinder {
radius: number;
height: number;
}
// 値空間。Javascriptに変換されてもこの値は残る。
// constはシンボルを値空間に宣言する。(=値を宣言する)
const Cylinder = (radius: number, height: number) => ({ radius, height });
interface Cylinder と、const Cylinder
は全くの無関係。
あなたがCylinder
とタイプした時、どちらを指しているかは、文脈次第だ。このことが、エラーを起こすことがある。
function calculateVolume(shape: unknown) {
if (shape instanceof Cylinder) {
shape.radius;
// ~~~~ プロパティ 'radius' は型 '{}' に存在しません。
}
}
何が起きている?このコードの意図はなんとなく分かる。
unknown である shape の型がもしCylinder
であるならば、プロパティradius
を持っていると判断して、それを利用したいのだ。でもinstanceof
は JavaScript の実行時の演算であり、値空間に作用するのだ。
つまり、この instanceof Cylinder の Cylinder は、const で宣言されている、関数の方の Cylinder を指しているのであり、interface Cylinder は全く関係ない。
どんなときでも一目で、シンボルが値空間にあるのか型空間にあるのか、明らかに分かるとは限らない。そのシンボルがどちらの文脈で登場しているかを見極める必要がある。これが特に紛らわしい、型空間の表現が、値空間の表現ととても似ているからだ。
例えば、リテラルだ。
type T1 = "string literal";
type T2 = 123;
const v1 = "string literal";
const v2 = 123;
一般的に、type
やintercae
のあとに続くシンボルは型空間のもので、const
やlet
による宣言の場合は値空間内のものである。
この感覚を養うのに便利なのが、TypeScript Playground を活用することだ。ここでは、書いた TypeScript から生成される JavaScript をその場で表示してくれるの。コンパイルによって型が消去されている。消去されたら、それは型空間のものということが分かる。
ひとつのステートメントの中で、型空間と値空間が混在していることもある。
型宣言:
や型アサーションas
のあとに続くシンボルは型空間内のものである。
=
に続くシンボルはすべて値空間のものである。
例えば、
interface Person {
first: string;
last: string;
}
const p: Person = { first: "Jane", last: "Jacobs" };
関数のなかでも以下のように 2 つの空間を行き来する。
function greetPerson(person: Person, greeting: "hello" | "goodbye") {
console.log(`${greeting}, ${person.first} ${person.last}`);
}
class
やenum
は型空間、値空間の両方に導入される。
最初の例でいうと、Cylinder
をクラスにすればうまくいく。
class Cylinder {
constructor(public radius: number, public height: number) {}
}
function calculateVolume(shape: unknown) {
if (shape instanceof Cylinder) {
shape; // OK, type is Cylinder
shape.radius; // OK, type is number
}
}
class 宣言によって導入された Typescirpt の型は、そのプロパティとメソッドで成り立っている。
もし、Cylinder が固定値を持つもので class 宣言の中に定数を持つ場合、その定数は、当然値空間のものになる。
class Cylinder {
radius = 1;
height = 1;
}
function calculateVolume(shape: unknown) {
if (shape instanceof Cylinder) {
shape; // OK, type is Cylinder
shape.radius; // OK, type is number
}
}
型空間・値空間で意味の違う演算子やキーワード
typeof
typeof は型空間
interface Person {
first: string;
last: string;
}
const p: Person = { first: "Jane", last: "Jacobs" };
function email(person: Person, subject: string, body: string): void {
// ...
}
// 型空間
type T1 = typeof p; // T1は Person
type T2 = typeof email; // T2は (person: Person, subject: string, body: string) => Response
// 値空間
const v1 = typeof p; // 文字列"object"
const v2 = typeof email; // 文字列"function"
console.log(v1); // "object"
console.log(v2); // "function"
console.log(T1); // ~~~~ 'T1' は型のみを参照しますが、ここで値として使用されています。ts(2693)
型空間では、typeof
は値を受け取って、その値の Typescript の型を返す。(値ではない)
値空間では、typeof は単なる JavaScript の演算子である。その値の「型」を文字列で返す。この「型」は、TypeScript の型のことではない!
JacaScript の実行時における「型」は TypeScript の型と違い、極めてシンプルだ。歴史的な経緯もあり、たった 6 種類しかない。
string
, number
, boolean
, undefined
, object
, function
だけだ。
type T1 = (typeof p)[keyof typeof p]; // T1は Person
どちらの typeof も、型ではなく、値にしか適用できないという点では共通している。
class
キーワードは、値と型の両方にシンボルや構造を導入するが、そうなるとtypeof <クラス名>
とすると、どうなるのだろうか。どちらの typeof なのかをどうやって判別するのだろう?これは文脈による。
const v = typeof Cylinder; // "function"
type T = typeof Cylinder; // Type is typeof Cylinder
const で導入されるシンボルは値である。よって、1 つ目の typeof は、JavaScript の typeof なので、v はstring
, number
, boolean
, undefined
, object
, function
のどれかだ。JavaScript において、クラスはコンストラクタ関数として実装されているので、この場合、v には文字列"function"が格納される。(ここでは TypeScript の typeof ではなく、JavaScript の typeof が使われている、ということが重要である。)
2 つ目のT
は type に続いて導入されているので、これは型である。しかしこの T は、TypeScript の型Cylinder
とは異なる。typeof で参照しているのはあくまでも JavaSript クラス、つまりコンストラクタ関数だからだ。
typeof
type T = typeof Cylinder; // JavaScriptのclassのコンストラクタ関数(new (nu))
const t1: T = new Cylinder(1, 1); // プロパティ 'prototype' は型 'Cylinder' にありませんが、型 'typeof Cylinder' では必須です。
TypeScript の型 Cylinder を別の名前で再利用したいのなら、以下のように typeof をつけずに型空間のシンボルにそのままアクセスすればよい。
type T = Cylinder; // TypeScriptの型Cylinderをそのまま代入している
const t1: T = new Cylinder(1, 1); // OK
また、コンストラクタ関数からそのクラスの型空間での型を導出したいときは、ユーティリティ型のInstaceType
を使えばよい。
これは名前の通りで、JavaScript のコンストラクタ関数を受け取って、そのクラスのインスタンスの型を返すものだ。
(今回の例では、単にCylinder
を型として指定すればよいだけなので、無意味な操作ではある。しかし、値空間と型空間の行き来をすることで、この 2 つの概念を理解することの助けになるだろう。
https://www.typescriptlang.org/docs/handbook/utility-types.html
type T = typeof Cylinder; // JavaScriptのclassのコンストラクタ関数(new (nu))
type T1 = InstanceType<T>; // T1 is Cylinder
const t1: T1 = new Cylinder(1, 1); // プロパティ 'prototype' は型 'Cylinder' にありませんが、型 'typeof Cylinder' では必須です。
演算子[]
も、typeof
と同じく、型空間と値空間で同じように見えるものだ。しかし、obj["field"]
とobj.field
が値空間では等価であるのに対して、型空間ではそうはいかない。前者の方は、他の型のプロパティを型として取得したいときに使う。
const first: Person["first"] = p["first"];
// ----- ---------- Values
// ----- --------- Types
Person["first"]
は:
の後に続いているので、型であり、[]
の中に入れられるのも型である。上の例では、リテラル型("first")を入れている。これが値ではなく、型であるということが一見分かりにくいので注意。
interface Person {
first: string;
last: string;
birthDate: Date;
}
type PersonNameProps = Person["first" | "last"];
type PersonAllProps1 = Person["first" | "last" | "birthDate"]; // Type is string | Date
type PersonAllProps2 = Person[keyof Person]; // Type is string | Date
type Tuple = [string, numebr, Date];
type TupleEl = Tuple[string];
その他
JavaScript と名前が被っている TypeScript の型空間キーワードは他にもある。
this
TypeScript の型表現でもthis
が出てくるが、これはポリモーフィズム(多態性)の this である。クラスメソッドの返値にの型として指定して、メソッドチェーンを実現するのに使う。
以下のように、ScrollableElement の render の戻り値は、this であり、これは ScrollableElement であるので、scrollY を呼び出せる。
継承状況に応じて、クラスメソッドの戻り値を動的に指定したいときに使う。
class HtmlElement {
render(): this {
// ...
}
}
class ScrollableElement extends HtmlElement {
override render(): this {
// ...
}
scrollY(y: number): this {
// ...
}
}
const scrollabelElem = new ScrollableElement().render.scrollY(100);
& |
値空間だと、これらはビット演算だが、型空間だと、インターセクション型やユニオン型を表現する
const
値空間のconst
は、新たに変数を導入するのに使う。
一方as const
は型空間のものである。
const red = {
r: 255,
g: 0,
b: 0,
};
// Type is {r: number; g: number; b: number;}
const redConst = {
r: 255,
g: 0,
b: 0,
} as const;
// Type is {r: 255; g: 0; b: 0;}
導入する変数の型を推論された型ではなく、もっと厳しく、設定したプロパティの値に固定したいときに使う。(https://typescriptbook.jp/reference/values-types-variables/const-assertion)
extends
JavaScript のクラスから、それを継承したサブクラスを作りたいときにclass A extends B
のように使うが、TypeScript の型表現にも使う。interface A extends B
など。
in
for 文のループに in キーワードが出てくるが型空間で Mapped Type を使いたいときにも in が出てくる(https://typescriptbook.jp/reference/type-reuse/mapped-types)
まとめ
TypeScript が自分の書いたコードをうまく認識してくれていないように思えるときは、もしかすると、値空間と型空間の理解を誤っていることが原因かもしれない。
例えば email 関数をリファクタすることになったとしよう。
function email(person: Person, subject: string, body: string): void {
// ...
}
function email(options: {
person: Person;
subject: string;
body: string;
}): void {
// ...
}
JavaScript であれば分割代入を使って、引数のオブジェクトのプロパティを、それぞれ変数に代入できる。
function email({ person, subject, body }) {
// ...
}
しかし、TypeScript で同じことをしようとすると、エラーとなる。
function email({
person: Person,
// ~~~~ バインド要素 'Person' には暗黙的に 'any' 型が含まれます。
subject: string,
// ~~~~ 識別子 'string' が重複しています。
// バインド要素 'string' には暗黙的に 'any' 型が含まれます。ts(
body: string
// ~~~~ 識別子 'string' が重複しています。
// バインド要素 'string' には暗黙的に 'any' 型が含まれます。ts(
}) {
// ...
}
問題は、Person
やstring
は値空間に置かれているということだ。
このコードでは、Personという変数、2つのstringという変数を参照しようとしていることになる。
こうではなく、値と型を分ける必要がある。
function email({person, subject, body}: {
person: Person,
subject: string,
body: string
}) {
// ...
}
値空間と型空間を明確に分けてコードを書いたり読むことで、より快適にTypeScriptを扱えるだろう。