概要
TypeScriptのunsafeな操作まとめのお勉強ノートです。
この記事の中には、全くの予想外なものも多くありますが、当たり前なものとか、そう意図されて作られていると思われるものも多数採用されているので、僕の主観から大きく外れた例を実際に検証してみて、以下にまとめました。
エディタにコピペして、最新のTypeScriptがどう判定するかを見てみます。矛盾を検知すると、TypeScriptはneverと判定するので、矛盾を検出できているかどうかがわかります。
観察結果をコメントに示します。
読むのをサポートするために、軽い解説も付しました。
型の破綻ってなにがやばいの?
TypeScriptは、「型付きのJavaScript」を標榜しています。つまり、JavaScriptでは型をサポートしてなくてあまりにも書き方の自由度が高すぎるから、それを制限して「書くと読みにくくてやばいパターンをちゃんと指摘しよう」、というモチベーションなわけです。元々のJavaScriptを「書き方の制限」をすることはあっても、JavaScriptにできない動き(=書き方の拡張)をするつもりはないんですね。
そんなわけで、「TypeScriptではJavaScriptで出ないエラーも出るけれども、TypeScriptで絶対でないエラーは、JavaScriptでも確実に出ない」という安心感がありがたいわけです。逆はこまりますね。書いてる時は絶対安心だよ!って言ってきて、動かしてみるとエラー。おぉこわいこわい。しかし、型が破綻してしまうと、「TypeScriptで書いてたら「ありえないエラー」だったのに、実際に動かしたら全然違う分岐に入ってエラー出た!」とかが出てきてしまいます。
って理解で合ってますかw
環境
VS Codeで、最新のTypeScript4.5.0-dev.20210831を用いて検証してます
濃縮ジュースはここです
以下のサンプルコードは全て、上記の記事の引用です
コメントと細部のみ編集しています
非自明な副作用
こちらの関数fは、オブジェクトxを引数にとって、そのプロパティaに代入を試みます。結果として、プロパティaの型が破綻します。
function f(x: { a: string | null }) {
x.a = null;
}
const obj = { a: "str" };
f(obj);
// nullを代入したのにstring判定
obj.a
非自明な副作用その2
同様の事態はクラスメソッドの呼び出しでも起こります。
今回はクラスHogeのsetXNullを呼び出すことで、インスタンスのプロパティxをnullに変更します。
しかし型システムはメソッドの副作用を考慮できないので、その前の条件分岐の!== null条件からxをstringだと判断してしまいます。
class Hoge {
x: string | null = "str";
setXNull() {
this.x = null;
}
f() {
if (this.x !== null) {
this.setXNull();
// this.x: string->null
// 直前でnullになったのにstring判定
this.x
}
}
}
非自明な副作用その3?詳細不明
元記事を書いた方は「似た例でも、Promiseだと違う」のようなことをおっしゃっていますが、こちらではなんの挙動の変化も観察されませんでした。
async function sleep(ms: number) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(void 0);
}, ms);
});
}
class Hoge2 {
x: string | null = "str";
setXNull() {
this.x = null;
}
async f() {
if (this.x !== null) {
// this.setXNull();
await sleep(1000);
// this.x: string->null
// もちろんstring。
// ここは筆者の意図が不明
// setxnull入れても変わらないので不明
this.x
}
}
}
const hoge = new Hoge2();
hoge.f();
hoge.x
オブジェクト同士のユニオン型がどう考えてもおかしい件
これはめちゃくちゃ変です。f2は引数yを、「プロパティxを持っている」か、「プロパティa, bを持っている」のどちらかだと期待します。
しかし実際には、プロパティxとaだけ持っている、という例を許容してしまいます。
ちなみにですが、「じゃあ、引数yのプロパティの名前列挙してみて」と伝えてみると、なんと「never」=どんなプロパティ名もありえない、と返してきます。これにはびっくりしました。しかしよく考えてみるとこれは正しい挙動で、条件分岐の場所によって仮定できることが違うというだけでした。
function f2(y: { x: number } | { a: number, b: number }) {
if ("a" in y) {
// number判定。まぁこれは、a持ってればbも持ってるよね、という意味で正しい。
y.b
// ここでは"a"|"b"になる
type temp = keyof typeof y
}
// これ普通にバグだろwww(仕様)
// なぜかneverになる
// → 条件分岐しない限り、keyについて仮定できることがないということ
type temp = keyof typeof y
}
const obj2 = { x: 1, a: 1 };
// なぜか許容される
f2(obj2);
関数クロージャについて
これはあまりびっくりしませんでした。筆者の方と結果が違うので、typescriptのバージョン違いの影響かな?
thisはthistypeという特別な型で考慮されるので、条件分岐とかに直接には左右されません。(語弊)
class Hoge3 {
f() {
if(typeof this !== typeof Hoge3){
// 反応なし。thisはthisでしかないと判定される
// thistype
this
}
}
}
const f3 = new Hoge3().f;
f3();
だからnumberって言ったじゃん! 改め、「emptyって言ったじゃん!」
const arr: number[] = [];
/* numberが返る → 実際にはエラー */
const n = arr[0];
const obj4: { [key: string]: number } = {};
// 同じくnumberが返ってしまう
const s = obj4["key"];
更新: @hiroiku 様にコードの誤りと元記事の誤読についてご指摘いただきました。ありがとうございました。
構造主義型システムの落とし穴
型ってそもそも何?ってのがときにこういうコーナーケースで問題になります。考え方には名前主義と構造主義の二種類(他にもあるの?)がありますが、TypeScriptは構造主義です。破綻しやすくても実用的?よく知りません。
Pythonは元々名前主義なのが少しずつ構造主義を取り入れてるとか。
Q. 以下のコードのクラスAとクラスBは同じクラスでしょうか?
名前主義:Ans. ソースコードに『継承してます!』とか『代入しました!』とか書いてないから、違うクラス!
構造主義;Ans. 中身一緒だから、同じクラス!
class A {}
class B {}
function f4(x: A | number){
if(!(x instanceof A)){
// number判定されてるけどBも通るよねという
x
}
}
f4(new B());
構造主義型システムの落とし穴その2
とあるクラスHoge5は、型引数をとります。ここで、その型に{x: number}と{}の二種類を別々に設定してみました。
ところで、構造主義型システムは、{x: number}は、{}の全てのプロパティを含んでいる型だとも言えるので、この二つを継承関係にあると考えます。
{x: number}は{}を継承していると考えるとして、では、Hoge5<{x: number}>はHoge5<{}>を継承していると言えるでしょうか?
どちらもHoge5という同じクラスで、型引数だけが異なります。じゃあ、型引数の違いだけ考えればいいから、これは継承関係。。。??
ひとまず、TypeScriptでは「継承している」と考えます。「クラスHについて、型引数AがBを継承しているとき、 H<A>
も H<B>
を継承している。」
しかし、この矛盾は以下で出てきます。以下ではHoge5が型を引き取って、setXの引数の型チェックを行っています。
class Hoge5<T> {
constructor(private x: T){}
getX(): T {
return this.x;
}
setX(x: T){
this.x = x;
}
}
const x: Hoge5<{ x: number }> = new Hoge5({ x: 1 });
// xは左辺の型を継承していると判定されるので、代入可能
const y: Hoge5<{}> = x;
// 矛盾ここ
// Hoge<{x: number}>のsetX引数はプロパティxを持っていなくてはならない
// でもエラーは出ない
y.setX({});
Objectのプロパティを直接触る感じのやつらは危ない?
この点に関して元記事様の説明もわかりやすかったのでぜひ!
Object.assignは、オブジェクト同士を合成してプロパティを共有する関数です。Object.definePropertyとかも危ない印象ですが、元記事様では「見かけによらない」とされていますので、さてどうでしょう。
ところで、
const obj = Object.assign({}, { x: 1 }, { x: "xxx" });
元記事様の執筆時点では左辺が「never」、到達不可能と判断されていて、そこが破綻だったようですが、現在では
const obj: {
x: number;
} & {
x: string;
}
と注釈されるため、特に問題ないかと思われます。
おまけ
最後に、元記事様がちらりと言及していた、「unsafe」 な関数シグネチャpick<T, K extends keyof T>(obj: T, keys: K[]): { [P in K]: T[P] }
を実際に実装した例を載せておしまいにします。
function pick<T, K extends keyof T>(obj: T, keys: K[]): { [P in K]: T[P] } {
return keys.reduce((result, key) => {
result[key] = obj[key];
return result;
}, {} as { [P in K]: T[P] });
}
引数objのキーがリテラルタイプとして扱われていること、そしてそのリテラルタイプに対して返り値のタイプが変わってしまうことがunsafeなんだそうです。やっぱり空オブジェクト周りとかかな、と思ってちょっとだけいじってみましたが、この実装で破綻を見つけることはまだできてないです。
まとめ
TypeScriptの型の破綻を見てきました。全体的に、プロパティへの代入で暗黙に型を変更するような操作に弱いということがわかりました。そのほかの点では最新の実装がちゃんと対応しているものもかなりありました。他には、二つの型をユニオンにしたとき、「プロパティの可能な組み合わせ」の検証が甘くなったり、型引数をとっていると引数のガードが甘くなったりするのは真面目に困る点ですね。
今後、ベストプラクティスを探すときには、unsafeな関数かどうかも気をつけていきたいものです。