7
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-07-09

みなさん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構文が使えると凄い楽になるので新機能として欲しいですね!!でも、型の世界の話しだけで済まなくなるので、たぶん実装されることはないんでしょうけど。

追記(2017/07/10)

なんか遊んでたら汎用的なPredicateTypeができました。

interface P<S, A: S> {
  apply(f: (s: S) => boolean): {
    refine(s: S): Promise<A>;
  }
}

const Predicate: P<*, *> = {
  apply: (f) => ({
    refine: (s) =>
      f(s) ? Promise.resolve((s:any))
           : Promise.reject(`Invalid Predicate: '${s}' does not satisfy '${f.toString()}'`)
  })
};

interface NonEmptyStringInterface {};
type NonEmptyString = string & NonEmptyStringInterface;

const NES = (Predicate: P<string, NonEmptyString>).apply(_ => _.length > 0);

(async () => {
  let value = await NES.refine('abc');
  value;
})();

どうせ値をAny Typeに変更しないといけないので、だったら型は関数の戻り型で適切に変換してやればいいやんの精神です。

そして、やっぱりPromiseはいけてないなーというのと、もっと短くしたいなーというのでもう少し作りこめみたい気持ちでいっぱいです。

7
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?