Kgtkr's Blog

TypeScriptのunsafeな操作まとめ

2018/06/23
typescript

初めに

TypeScript(以下TS)はJSに静的型システムを取り入れた言語です。
しかしTSの型システムには多くの穴があり、知らないと型の整合性を壊してしまいます。(型システムが健全でないという)
そこで今回はそのような操作をまとめてみました。
間違え、不足等があればコメントで指摘してくださると助かります。

この記事の目的

この記事は「TSの型システムの穴」を批判することが目的ではありません。
実行時のオーバーヘッドを無くすことや利便性などとのトレードオフであることは理解しています。
TSを書く多くの人が「このような操作をすると型の整合性が壊れることがある」ということを理解した上で使ってほしいというのがこの記事の目的です。

型システムの健全性とは

静的言語に限ると、「コンパイルが通ったなら実行時に型情報と値が矛盾しない事が保証されている」事を言います。
例えば「number型の変数に"hello"が入っている事は絶対にありえない」といった感じです。Javaなど多くの静的型付け言語の型システムは健全、つまりこのような事が保証されているので「当たり前では?」と思うかもしれませんがTSではこれが保証されていません。

なぜ型システムが健全になっていないのか

これは実行速度や利便性とのトレードオフです。
「とりあえず型をanyにする」という選択を認める事でJSから移行しやすくしたり、「キャスト時に実行時型チェックを行わない事でJSより遅くなるのを防げる」といったメリットもあります。

unsafeの定義

unsafeは公式の用語ではなく私が勝手にそう呼んでいるだけなので用語を定義します。

  • それ以前のコードで型システムの整合性が保たれていても、型システムの整合性を壊す可能性がある操作

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ブロックやunsafeというprefixがついた関数を使う事で危険な操作(型システムの健全性が保証されていない操作)を部分的に許している言語があります。
このようなunsafeな機能を持った言語には「unsafeな機能を使う箇所はなるべく少なくし、安全なインターフェイスを持った関数に閉じ込めるべき」という文化があります。
しかしTSには至る所にキャストが出てくるコードなど「unsafeな操作を閉じ込める」という文化があまりないように感じており、そういう文化になればいいなという思いもあってunsafeという言葉を使っています。
メタプログラミングなどどうしてもキャスト等が必要な場面はありますが、そういう操作は安全なインターフェイスを持った関数の実装に閉じ込めてなるべく使う箇所を少なくすることでより安全なTSコードになるのではないでしょうか。

バージョン、設定など

TypeScript:3.6.3を使います。
またstrictを有効にしている事を前提に話を進めます。
string型にnullやundefinedが入るなどといった場合もunsafeと見なします。
またstrictを有効にすることで安全になる操作も扱いません。

一覧

コメントのx: T->Xxの型はTだが、Xという値が入っているという事を表しています。

型変換関連

キャスト

おそらく最も代表的な例ですね。型システムの整合性は保証されません。

const a: string = 1 as any;
// a: string->1
const b = <string><any>1;
// b: string->1

non-null assertion operator

キャストと似ていますが、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

any型の使用

この問題を解決するためにunknown型があります。

const x: any = 1;
const y: string = x;
// y: string->1

Type Guard関連

とても便利な機能ですが再代入可能な変数であっても型ガードされるため、よく整合性を壊します。

他の関数からの代入

class Hoge {
  x: string | null = "str";
  setXNull() {
    this.x = null;
  }

  f() {
    if (this.x !== null) {
      this.setXNull();
      // this.x: string->null
    }
  }
}

async

ちなみに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;

x is T

関数の戻り値で使う「戻り値がtrueならxはTである」という事を示す型です。
isArrayなどで使われています。

function xIsString(x: any): x is string {
  return true;
}

const s: any = 1;
if (xIsString(s)) {
  // s: string->1
}

純粋でないgetter

こんな事する人はいないと思いますが

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
}

in演算子

{ 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);

this

JSのコンテキスト関連です。
TSの型システムではメソッドと関数の区別は行われず、メソッドを変数等に代入することが出来るため型システムを壊す事があります。

class Hoge {
  f() {
    // this: Hoge->undefined
  }
}

const f = new Hoge().f;
f();

declare

これも当然ですが、整合性の保証はされません。型定義ファイルも含みます。

declare const x: string;
// x: string->undefined

Definite Assignment Assertion

「プロパティは必ず初期化される」ということをコンパイラに教えてあげる文法なので当然初期化されるかはプログラマの責任です。

class Hoge {
  x!: string;
  f() {
    // x: string->undefined
  }
}

new Hoge().f();

indexアクセス

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

型チェックを無効にする機能です。当然何が起こるか分かりません。

// @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

instanceof

TSは構造的部分型を採用しているのでA型だからといってA型をextendsしている、つまりx instanceof Atrueになるとは限りませんがこれで型ガードできるようになっているのが原因で以下のような問題が発生します。

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 } -> {}

unsafeな関数

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です。