flowtypeで既存の型に述語を付与してassertを削除しよう

  • 1
    いいね
  • 0
    コメント

みなさんassertを使っていますか!!

function foo(str) {
  console.assert(typeof str === 'string');
  console.assert(str.length > 0);

  // 何かの処理
  str.charAt(0);
}

assertを使って事前条件を書いて開発し、unassertを使ってプロダクションにリリースするときに削除スタイルは最近良く見かけるようになりましたね。
動的型付けなJavaScriptで安全なコードを書けるようになるassert最高!!と言いたいところですが、実行時にしかチェックができないんですよね。
そこで、せっかくflowtypeで静的な型付けを行っているので、ここは事前にチェックできるように型で解決を行いたいと思います。

1. 型アノテーションを付与する

まずは先のコードに型アノテーションを付与して一つ目のassertを削除します。

function foo(str: string): void {
  console.assert(str.length > 0);

  // 何かの処理
  str.charAt(0);
}

foo(1); // Error

これでfoonumberbooleanを呼び出してもflowtypeの型チェックでエラーが出るようになりましたね!

2. 専用のクラスを作成する

次はconsole.assert(str.length > 0)を型で表現したいと思います。
とは言っても型で文字列の長さが1以上あることを表現することは難しいです。(型レベル自然数なら出来るとは思うけどflowtypeで作るのは大変だと思う)

そこで、まずは文字列の長さが1以上のチェック処理を行っているクラスを作って、それを受け取るように変更したいと思います。

class NonEmptyString {
  str: string;
  constructor(str: string) {
    console.assert(str.length > 0);

    this.str = str;
  }

  get(): string {
    return this.str;
  }
}

function foo(str: NonEmptyString) {
  // 何かの処理
  str.get().charAt(0);
}

foo(new NonEmptyString('abc'));

これで、foo関数の中からassertが消えシンプルになりましたね。

3. Intersection Typeに変更する

foo関数自体はだいぶシンプルになりましたが、NonEmptyStringではstringと同じように扱えないためちょっと面倒臭いですね。
NonEmptyStringStringを継承しても良いのですが、flowtype的にStringstringでは別物なため細々と不具合が出てきます。

そこで、Intersection Typeを使ってNonEmptyStringであり、stringである型を作ります。

declare class NonEmptyStringIdentifier {}
type NonEmptyString = string & NonEmptyStringIdentifier;

function foo(str: NonEmptyString) {
  str.charAt(0);
}

declare var str: NonEmptyString;
foo(str);

これで、foo関数内で通常の文字列として扱いつつ、assertを削除することができました。

4. NonEmptyStringに変換する関数を作成する

先の例ではdeclare varstringNonEmptyStringに変換する処理を端折ったので、そこを追加します。

declare class NonEmptyStringIdentifier {}
type NonEmptyString = string & NonEmptyStringIdentifier;

interface Predicate<S, A> {
  apply(s: S): ?A
}
const NES: Predicate<string, NonEmptyString> = {
  apply: (s) => {
    return s.length > 0 ? ((s:any):NonEmptyString) : null;
  }
}

function foo(str: NonEmptyString) {
  str.charAt(0);
}

let str = NES.apply('abc');
if (str) {
  foo(str);
}

もう少し真面目に作るならflowtypeでLens/Prismを実装する話があるので参考にしてください。

5. 検査エラーを返すようにする

ここで完成としても良かったのですが、検査エラーでnullを返すのはイケてないのでエラーを適切に返せるようにします。
真面目に作るならEitherやValidationを作っても良いのですが、今回は簡単にPromiseで表現することにします。

declare class NonEmptyStringIdentifier {}
type NonEmptyString = string & NonEmptyStringIdentifier;

interface Predicate<S, A> {
  apply(s: S): Promise<A>
}
const NES: Predicate<string, NonEmptyString> = {
  apply: s =>
    (s.length > 0) ? Promise.resolve(((s:any):NonEmptyString))
                   : Promise.reject(new Error('string is empty.'))
}

function foo(str: NonEmptyString) {
  str.charAt(0);
}

async () => {
  foo(await NES.apply('abc'));
};

Errorも専用のものを用意した方がより適切だとは思いますが、今回は面倒なので端折っています。

6. 完成

という訳でこれで完成です!
今回の例だとassertと違ってunassertでプロダクションにリリースしたときに削除できないため、関数呼び出しのオーバーヘッドなどパフォーマンス面が気になる方がいると思います。
しかし、実際にアプリケーションに導入する場合はフォームや、APIのレスポンスのJSONをオブジェクトに変換するときなど、外部から入力された値に対しては検査を行っているはずなので、そのタイミングで検査済みの値として述語を付与したNonEmptyStringのような型を返してやればそこまでパフォーマンスの劣化は起きないはずです。
(もし検査してなかったら、そもそもassertを消しちゃいけないよねという話でもありますけど)

逆にどういったときにassertを使うべきかと言えば、述語を付与した型を作るのが面倒臭いときぐらいなのかなと思います。
もし、こういったときはassertでないと難しいというものがあれば教えてください。

おわりに

そういえばflowtypeには$Pred<T>がありますが、あれはType Refinementsのための機能になりますので、ちょっと用途が違います。
今回の内容みたいなものでも%checks構文が使えると凄い楽になるので新機能として欲しいですね!!でも、型の世界の話しだけで済まなくなるので、たぶん実装されることはないんでしょうけど。