TypeScript(以下TS)はJSに静的型システムを取り入れた言語です。
しかしTSの型システムには多くの穴があり、知らないと型の整合性を壊してしまいます。(型システムが健全でないという)
そこで今回はそのような操作をまとめてみました。
間違え、不足等があればコメントで指摘してくださると助かります。
この記事は「TSの型システムの穴」を批判することが目的ではありません。
実行時のオーバーヘッドを無くすことや利便性などとのトレードオフであることは理解しています。
TSを書く多くの人が「このような操作をすると型の整合性が壊れることがある」ということを理解した上で使ってほしいというのがこの記事の目的です。
静的言語に限ると、「コンパイルが通ったなら実行時に型情報と値が矛盾しない事が保証されている」事を言います。
例えば「number型の変数に"hello"
が入っている事は絶対にありえない」といった感じです。Javaなど多くの静的型付け言語の型システムは健全、つまりこのような事が保証されているので「当たり前では?」と思うかもしれませんがTSではこれが保証されていません。
これは実行速度や利便性とのトレードオフです。
「とりあえず型をanyにする」という選択を認める事でJSから移行しやすくしたり、「キャスト時に実行時型チェックを行わない事でJSより遅くなるのを防げる」といったメリットもあります。
unsafe
は公式の用語ではなく私が勝手にそう呼んでいるだけなので用語を定義します。
TSの型システムでは例外を投げるかは保証されていないのでunsafeではありません。
function throwError(): string {
throw new Error();
}
const x = throwError(); // stringが返ってくると型は言っているのに例外が飛んできた
const x: string = {} as any;
const y: string = x; // yはstringのはずなのに{}が代入
これは1行目の時点で既に型システムの整合性が壊れているので、2行目の代入操作はunsafeではありません。
1行目のキャストがunsafeです。
静的型付け言語の中にはunsafeブロックやunsafeというprefixがついた関数を使う事で危険な操作(型システムの健全性が保証されていない操作)を部分的に許している言語があります。
このようなunsafeな機能を持った言語には「unsafeな機能を使う箇所はなるべく少なくし、安全なインターフェイスを持った関数に閉じ込めるべき」という文化があります。
しかしTSには至る所にキャストが出てくるコードなど「unsafeな操作を閉じ込める」という文化があまりないように感じており、そういう文化になればいいなという思いもあってunsafeという言葉を使っています。
メタプログラミングなどどうしてもキャスト等が必要な場面はありますが、そういう操作は安全なインターフェイスを持った関数の実装に閉じ込めてなるべく使う箇所を少なくすることでより安全なTSコードになるのではないでしょうか。
TypeScript:3.6.3を使います。
またstrictを有効にしている事を前提に話を進めます。
string型にnullやundefinedが入るなどといった場合もunsafeと見なします。
またstrictを有効にすることで安全になる操作も扱いません。
コメントのx: T->X
はxの型はTだが、Xという値が入っている
という事を表しています。
おそらく最も代表的な例ですね。型システムの整合性は保証されません。
const a: string = 1 as any;
// a: string->1
const b = <string><any>1;
// b: string->1
キャストと似ていますが、null/undefined許容型を非許容型に変換する演算子です。
const a: string | null = null;
const x: string = a!;
// x: string->null
const b: number | undefined = undefined;
const y: number = b!;
// y: number->undefined
この問題を解決するためにunknown
型があります。
const x: any = 1;
const y: string = x;
// y: string->1
とても便利な機能ですが再代入可能な変数であっても型ガードされるため、よく整合性を壊します。
class Hoge {
x: string | null = "str";
setXNull() {
this.x = null;
}
f() {
if (this.x !== null) {
this.setXNull();
// this.x: string->null
}
}
}
ちなみにPromiseを使うと起こりません。
async function sleep(ms: number) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve();
}, ms);
});
}
class Hoge {
x: string | null = "str";
async f() {
if (this.x !== null) {
await sleep(1000);
// this.x: string->null
}
}
}
const hoge = new Hoge();
hoge.f();
hoge.x = null;
関数の戻り値で使う「戻り値がtrueならxはTである」という事を示す型です。
isArrayなどで使われています。
function xIsString(x: any): x is string {
return true;
}
const s: any = 1;
if (xIsString(s)) {
// s: string->1
}
こんな事する人はいないと思いますが
class Hoge {
isGet = false;
get x(): string | null {
if (!this.isGet) {
this.isGet = true;
return "str";
} else {
return null;
}
}
}
const hoge = new Hoge();
if (hoge.x !== null) {
// hoge.x: string->null
}
{ x: number }
に{ x:number, y:number }
を代入することは当然出来ますが…
function f(x: { x: number } | { a: number, b: number }) {
if ("a" in x) {
// x.b: number->undefined
}
}
const obj = { x: 1, a: 1 };
f(obj);
JSのコンテキスト関連です。
TSの型システムではメソッドと関数の区別は行われず、メソッドを変数等に代入することが出来るため型システムを壊す事があります。
class Hoge {
f() {
// this: Hoge->undefined
}
}
const f = new Hoge().f;
f();
これも当然ですが、整合性の保証はされません。型定義ファイルも含みます。
declare const x: string;
// x: string->undefined
「プロパティは必ず初期化される」ということをコンパイラに教えてあげる文法なので当然初期化されるかはプログラマの責任です。
class Hoge {
x!: string;
f() {
// x: string->undefined
}
}
new Hoge().f();
JSの仕様上、存在しないインデックスを指定しても例外を投げずにundefined
を返しますが、TSの型システムはT | undefined
ではなくT
を返すと見なすので型システムを壊す事があります。
issue
const arr: number[] = [];
const n = arr[0];
// n: number->undefined
const obj: { [key: string]: number } = {};
const s = obj["key"];
// s: number->undefined
型チェックを無効にする機能です。当然何が起こるか分かりません。
// @ts-ignore
const x: string = 1;
// x: string->number
function f(x: { a: string | null }) {
x.a = null;
}
const obj = { a: "str" };
f(obj);
// obj.a: string->null
TSは構造的部分型を採用しているのでA
型だからといってA
型をextends
している、つまりx instanceof A
がtrue
になるとは限りませんがこれで型ガードできるようになっているのが原因で以下のような問題が発生します。
class A {}
class B {}
function f(x: A | number){
if(!(x instanceof A)){
// x: number -> {}
}
}
f(new B());
TSはメソッドの引数に型パラメーターが使われていても共変性を持ちます。
共変とはA extends B
の時F<A> extends F<B>
となる性質です。引数に型パラメーターが使われている時本来これは健全ではないのですが、利便性などとのトレードオフでこれが許されているので以下のような事がありえます。
引数に代入
と問題の種類としては似ています。
なぜ TypeScript の型システムが健全性を諦めているかという記事に詳しく書いてあるので読んでみて下さい。
class Hoge<T> {
constructor(private x: T){}
getX(): T {
return this.x;
}
setX(x: T){
this.x = x;
}
}
const x: Hoge<{ x: number }> = new Hoge({ x: 1 });
const y: Hoge<{}> = x;
y.setX({});
// x.getX(): { x: number } -> {}
TSには型定義のバグ、型定義の限界、利便性とのトレードオフなどによって特定のケースで型と矛盾した結果を返す型定義を持った関数がかなりの数存在します。これは関数によりますし簡単に見分ける方法はないのですが代表例としてObject.assign
を紹介します。
const obj = Object.assign({}, { x: 1 }, { x: "xxx" });
// obj.x: never -> "xxx"
never
型はあらゆる型に代入可能だが、値を持たないボトム型と呼ばれる型です。これ自体は安全な型なのですが本来never
型が出てくるコードは例外などで到達不能でなければいけません。しかし到達できているのでunsafeです。
他にも{ x?: string }
と{ x: string | undefined }
の区別が曖昧な事に起因するunsafeな型定義や、オブジェクトのキーをリテラル型として受け取り返り値がキーに関して共変、例えばpick<T, K extends keyof T>(obj: T, keys: K[]) => { [P in K]: T[P] }
のようなシグネチャを持った関数は基本的にunsafeです。