Kgtkr's Blog

TypeScriptでtype predicateを安全に使う方法

2019/07/16
typescript

TL;DR

import { isT, isNotT, defineIsT } from "safe-type-predicate";

// isA: (x: "a" | "b") => x is "a"
const isA = defineIsT((x: "a" | "b") =>
    x === "a" ? isT(x) : isNotT()
);

GitHub

safe-type-predicate

type predicateとその問題点

TypeScriptにはtype predicateという機能が存在する。
これは適当な型の値xを受け取りbooleanを返す関数の戻り値をx is Tと書くことで、trueを返せばxT型、falseを返せばそうでないことを表す機能である。
これによってユーザー定義関数で型ガードを行うことを可能にしている。
例えばunknown型、つまり任意の型を受け取りその値がstring型であるかを返す関数は次のようになる。

function isString(x: unknown): x is string {
  return typeof x === "string";
}

これは便利な機能であるが大きな問題が存在する。それは型チェックがほとんど効かないことである。
例えば以下のコードは実際はnumber型であるかを判定する関数なのに、型上ではstring型であるかを判定する関数になってしまっている。

function isString(x: unknown): x is string {
  return typeof x === "number";
}

この程度の単純な例でも型チェックが行われないためちょっとしたミスがバグの原因になりやすい。
そこでこの記事ではtype predicateを安全に使う方法と、それをライブラリ化したsafe-type-predicateを紹介する。

type predicateを安全に使う方法

type predicateを安全に扱うとはつまり(x: T) => x is Rな関数を安全に定義することである。
もちろんTypeScriptなので完全な安全は難しい。そこで一定の書き方に従っている限り安全であることを目指すことにした。
そしてこれを実現するために、型ガードと型推論を上手く生かす事を考えた。

そして以下のような書き方ができれば良いという発想になった。

const isHoge = defineIsT((x: /* 引数に取る型 */) =>
    /* 条件式 */ ? isT(x) : isNotT()
);

このように書ければ条件式のおかげでisT(x)の時点のxは型ガードによって絞り込まれているので型推論可能である。
また値を考えると(つまりJSへのコンパイル結果)、defineIsTは恒等関数、isTisNotTはそれぞれtruefalseを返す定数関数とすればよくシンプルである。
あとは上手く型をつけるだけである。

それで出来たものが以下である。

declare const isTSymbol: unique symbol;
declare const isNotTSymbol: unique symbol;

export type isT<T> = true & { _T: T; _Tag: typeof isTSymbol };
export type IsNotT = false & { _Tag: typeof isNotTSymbol };

export function isT<T>(_x: T): isT<T> {
  return true as isT<T>;
}

export function isNotT(): IsNotT {
  return false as IsNotT;
}

export function defineIsT<T, R extends T>(
  f: (x: T) => isT<R> | IsNotT
): (x: T) => x is R {
  return f as any;
}

IsT<T>はnew typeと幽霊型のテクニックを、IsNotTはnew typeのテクニックを使っている。
new typeは元の型には変換出来るが、元の型からはキャストなしでは変換出来ない型である。これはなくても動作するが、あったほうがisT, isNotTの戻り値以外の値が入ることを防止出来るのでより安全である。ここでは& { _Tag: typeof isTSymbol }& { _Tag: typeof isNotTSymbol }の部分である。
幽霊型は型に別の型の情報を残すテクニックである。ここでは& { _T: T }の部分でTという型情報を残している。

これを使うことで以下のように書くことが出来るようになった。

// isString: (x: unknown) => x is string
const isString = defineIsT((x: unknown) =>
    typeof x === "string" ? isT(x) : isNotT()
);

そしてここまで書いた事はsafe-type-predicateという名前でライブラリ化して公開している。

カスタムtslintルール

safe-type-predicateを使う事で何もしないよりはかなり安全にtype predicateを使うことが出来るようになった。
しかし以下のような書き方をされれば当然型システムと動作に矛盾が生じる。

// isString: (x: unknown) => x is string
const isString = defineIsT((x: unknown) =>
    isT("x")
);

なぜなら「一定の書き方に従っている限り安全」ということを目指して作ったライブラリだからだ。
そこで「一定の書き方」を強制させればよいのではないかと考えtslintルールを作り、tslint-safe-type-predicateという名前で公開した。
npm i -D tslint-safe-type-predicateして、tslint.jsonextends"tslint-safe-type-predicate"を追加するだけで使える。
こうすることで例えば上の例では以下の画像のような警告を出してくれる。