asterisc

技術ネタ中心にその他雑多なこと

フロントエンド向けvalidator: favalidの紹介

はじめに

少し前にフロントエンドのjs向けvalidatorフレームワークfavalid というものを作ったので、その紹介記事を書きます。

favalidの特徴

フロントエンドというかjs向けvalidatorといえば joiyupv8nといったようなライブラリがありますが、それらに比べると機能を大幅に削り、とにかく軽量なのと関数型ライクなAPIがfavalidの特徴です。デフォルトのバリデーションメッセージすら入ってないので自分で定義しないとダメです。 ユースケースやコンフィグによりますが、最小構成でwebpackでbundleすると、1kbくらい。機能を色々盛り込んでも~10kbくらいになるかと思います。gzipを効かせれば容量は更に削減されるかと思います。

使い方

ここからは favalid の使い方を説明します。

インストール

普通に npm ないしは yarn で入ります。

$ npm install favalid

基本機能の紹介

READMEにのせている使い方をベースに基本機能を説明します。 *以下に載せている正規表現等はあくまでサンプルです。実際に使用する場合は注意してください。

tester

favalid のコアは tester という関数です。 tester は以下の関数を引数にとる高階関数です。

  • 第1引数にvalidation対象の値を受け取り、validation errorが発生していないかどうかのbool値を返す関数
  • 第2引数にvalidation対象の値を受け取り、validation error messageの文字列を返す関数 (= この関数を messager と呼んでいます)

tester の返り値はvalidation対象の値を受け取り、 {error: boolean; message: string} のオブジェクトを返す関数です(= この関数をfavalidの文脈で validator と呼びます)。 第1引数で渡された関数を元に値を検証し、 error=true であれば第2引数の関数を実行してerror messageを取得します。

以下にシンプルな例を載せます。ES2015以降の文法で記載します。

import { tester } from 'favalid';

const validator = tester((num) => {
    return num < 10
}, (num) => {
    return `${num}は10より小さい数字である必要があります。`
});

console.log(validator(10)); // <=  {error: true, message: '10は10より小さい数字である必要があります。'}
console.log(validator(9)); // <= {error: false, message: ''}

combine

tester だけであればただの使いづらいAPIですが、 favalidcombine という関数を提供しています。 combine は複数の tester の返り値のvalidatorをまとめてvalidatorを返す関数です。

以下に例を示します。

import { tester, combine } from 'favalid';

const validator1 = tester((num) => {
    return num < 10
}, (num) => {
    return `${num}は10より小さい数字である必要があります。`
});

const validator2 = tester((num) => {
    return num > 2
}, (num) => {
    return `${num}は2より大きい数字である必要があります。`
});

const combined = combine(validator1, validator2);

console.log(combined(10)) // <= {error: true, message: '10は10より小さい数字である必要があります。'}
console.log(combined(1)) // <= {error: true, message: '1は2より大きい数字である必要があります。'}

combine された validatorvalidator なので、さらに combine することができます。

// ... 前の続き 

const validator3 = tester((num) => {
    return num % 2 === 0;
}, (num) => {
    return `${num}は2で割り切れる必要があります。`
});

const combined2 = combine(combined, validator3);

console.log(combined2(10)) // <= {error: true, message: '10は10より小さい数字である必要があります。'}
console.log(combined2(1)) // <= {error: true, message: '1は2より大きい数字である必要があります。'}
console.log(combined2(5)) // <= {error: true, message: '5は2で割り切れる必要があります。'}

このように、 favalid では tester を用いて細かい粒度でvalidatorを実装し、 combine を用いてユースケースに応じたvalidatorを構成していくことが基本となります。

pre-defined validators

すべて自分で tester を使って実装するのは大変なので、よく使うルールについてはフレームワーク側で提供しています。これらを pre-defined validators と呼びます。 これらの validator は自分で作った validator と組み合わせることも可能です。 これらを使って、 10文字以上100文字以内のemailアドレス のルールを持ったvalidatorを作ります。

import {
  combine,
  maxLength,
  minLength,
  regexp
} from "favalid";

const minLengthMessage = () => 'メールアドレスは10文字以上です。';
const maxLengthMessage = () => 'メールアドレスは100文字以内です。';
const regexpMessage = () => 'メールアドレスのフォーマットが不正です。';
const mailRegexp = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;

const emailValidator = combine(minLength(10, minLengthMessage), maxLength(100, maxLengthMessage), regexp(mailRegexp, regexpMessage, {}));

このようにいくつか実装がeasyになるようなAPIを提供しています。 冒頭にも解説しましたが、validation messageは favalid では提供していません。なので、全て自分で定義する必要があります。

regexp のpre-defined validatorの実装を見てみましょう。実装ファイルは これ です。

export default (
  regex: RegExp,
  messager: Messager,
  { exclude = false }: IRegExpOption
): Validator => {
  if (exclude) {
    return tester((target: string) => !regex.test(target), messager);
  } else {
    return tester((target: string) => regex.test(target), messager);
  }
};

optionとしてexclude オプションを取れるようになっており、正規表現にマッチしたのか、してないのかをルールで出し分けることができます。 内部実装としては tester を用いて実装していますが、これは pre-defined validator 全てに共通する特徴です。 tester を用いて実装しているので、共通のインターフェイスを保つことができ、全て combine によりvalidator同士を結合することができます。 pre-defined validatorここ に定義されています。必要なものを探していただければと思います。

応用的な使い方

上記の基本機能を踏まえた上で、以下に応用的な使い方を載せていきます。

Object Schema Validation

favalid の基本機能では値のバリデーションしかサポートしていませんが、objectのvalidationを可能にする shape という関数を提供しています。 objectを引数にとり、 key に結びついたvalidatorを用いて値のチェックを行えます。

import {
  combine,
  maxLength,
  minLength,
  regexp,
  shape // <= これ
} from "favalid";

/** ここからは上のemail validatorと同じ */
const emailMinLengthMessage = () => 'メールアドレスは10文字以上です。';
const emailMaxLengthMessage = () => 'メールアドレスは100文字以内です。';
const emailRegexpMessage = () => 'メールアドレスのフォーマットが不正です。';
const mailRegexp = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;

const emailValidator = combine(
  minLength(10, emailMinLengthMessage),
  maxLength(100, emailMaxLengthMessage),
  regexp(mailRegexp, emailRegexpMessage, {})
);

/** ここまで上と同じ **/

const passMinLengthMessage = () => 'パスワードは8文字以上です。';
const passMaxLengthMessage = () => 'パスワードは16文字以内です。';
const passRegexpMessage = () => 'パスワードは大文字小文字数字記号が含まれていなければいけません。';
const passRegexp = /^(?=.*?[a-z])(?=.*?\d)(?=.*?[!-\/:-@[-`{-~])[!-~]/i;

const passwordValidator = combine(
  minLength(8, passMinLengthMessage),
  maxLength(16, passMaxLengthMessage),
  regexp(passRegexp, passRegexpMessage, {})
);

const userValidator = shape({
  email: emailValidator,
  password: passwordValidator,
});

/** これが出力される
{ email: { error: false, message: '' },
  password: { error: true, message: 'パスワードは8文字以上です。' } }
**/
console.log(userValidator({
  email: 'hoge@hogehoge.com',
  password: '1234',
}));


/** これが出力される
{ email: { error: true, message: 'メールアドレスのフォーマットが不正です。' },
  password: { error: true, message: 'パスワードは大文字小文字数字記号が含まれていなければいけません。' } }
**/
console.log(userValidator({
  email: 'hogehogehoge.com',
  password: 'Hoge12345',
})); 

ErrorReducerを使う

favalid のdefaultの挙動は、 combine された validatorcombine された順に実行していき、error messageは最初にvalidation errorが発生したときのものを採用しています。

例えば、上記のemailValidatorの場合、 aaa という文字列を入れたとき、 minLength のルールと regexp のルール両方に違反することになるのですが、error messageの値として得られるのは最初に実行される minLength のルールのものになります。

console.log(emailValidator('aaa'));  // { error: true, message: 'メールアドレスは10文字以上です。' } が出力される

この挙動をユーザが任意に変更できます。combineする際に、errorをどう扱うかを定義できる reducer を渡すことができる combineWithReducer というAPIがあります。 例えば、messageをすべて配列に格納し、違反したメッセージをすべて取得するような reducer を定義します。

const reducer = (prevResult, currentResult) => {
  if (currentResult.error) {
     prevResult.error = true;
    prevResult.message.push(currentResult.message);
  }
  return prevResult;
};

const emailValidatorWithReducer = combineWithReducer(
  [
    minLength(10, emailMinLengthMessage),
    maxLength(100, emailMaxLengthMessage),
    regexp(mailRegexp, emailRegexpMessage, {}),
  ],
  reducer,
  {error: false, message: []}
);

/* 以下のように表示される
{ error: true,
  message:
   [ 'メールアドレスは10文字以上です。',
     'メールアドレスのフォーマットが不正です。'  ] 
 }
*/
console.log(emailValidatorWithReducer('aaa'));

combineWithReducer の第1引数にvalidatorの配列、第2引数に reducer 、第3引数にerrorの初期値を渡します。 reducer は第1引数に前回までのvalidationの結果が格納され、第2引数に現在のvalidationの結果が入ってきます。validationの結果を任意に加工して、returnすることができます。

値を複数とるvalidator

パスワードの確認フォームで、前に入力されたパスワードの値と同一かどうかをチェックするvalidatorなど、値を複数とるvalidatorが存在します。 favalid では testerの第1引数の関数および第2引数の関数は可変長の値を取ることができます。 tester および messager が引数を一つしかとらないように定義されている場合、第2引数以降のinputは無視されます。

以前作った passwordValidator を再利用します。

const confirmValidator = tester((passwordConfirm, password) => {
  return passwordConfirm === password
}, (passwordConfirm, password) => {
   // あくまでサンプルです、ユーザが入力したパスワードをそのままメッセージに使うのは絶対にやめましょう!
  return `${passwordConfirm}は${password}と一致しません。`
});

const passwordConfirmValidator = combine(passwordValidator, confirmValidator);

console.log(passwordConfirmValidator('123', 'Passw0rd!123')) // { error: true, message: 'パスワードは8文字以上です。' }
console.log(passwordConfirmValidator('password!', 'Passw0rd!123')) // { error: true, message: 'パスワードは大文字小文字数字記号が含まれていなければいけません。' }
console.log(passwordConfirmValidator('passw0rD!123', 'Passw0rd!123')) // { error: true, message: 'passw0rD!123はPassw0rd!123と一致しません。' }

非同期のvalidation

emailの重複チェックなど、一度サーバサイドに問い合わせてvalidationしたい場合があります。 favalid のデフォルトは同期の挙動ですが、 async 版の asyncTesterasyncCombine と同期のvalidatorを非同期に変換する toAsync というAPIを提供しています。

先程作ったemailのvalidatorを再利用して、サーバサイドにメールの重複を問い合わせる版のemailのvalidatorを作ってみます。

asyncTester の第一引数はPromiseを返す関数で、 resolve した際に true/falseの値を返すことを期待します。 asyncTester の返り値は非同期のvalidatorとなります。

import { asyncCombine, asyncTester, toAsync } from 'favalid';

const USERDB = {
  "example@hello.com": true
};

// API requestをstubする。
const apiRequest = async (email) => {
  if (USERDB[email]) {
    return false
  }
  return true
};

const asyncEmailValidator = asyncCombine(
  toAsync(emailValidator), // 以前作ったemailValidatorを非同期化
  asyncTester(apiRequest, (email) => `${email} はすでに存在しています`)
);

async function testing() {
  console.log(await asyncEmailValidator('hoge.com')); // { error: true, message: 'メールアドレスは10文字以上です。' }
  console.log(await asyncEmailValidator('email@hoge.com')); // { error: false, message: '' }
  console.log(await asyncEmailValidator('example@hello.com')); // { error: true, message: 'example@hello.com はすでに存在しています' }
}

testing();

今後の機能改善の予定

  • 型を付ける
    • せっかくtypescriptで書いているのに、genericsなどの機能が使いこなせておらず、残念な実装になっています。そこをもうちょいなんとかする予定です。
  • ramda.jsのリプレイス
    • 依存でramda.jsが入っていますが、ごくごく一部の機能しか使っていないので、除去する予定です。
  • ドキュメント
    • がんばります...
  • i18n
    • どういうAPIにするか考えている最中ですが、i18nにも対応したいと思います。

それ以外にも機能のリクエストなどありましたら教えていただけると幸いです。

まとめ

frontend javascript向けvalidator favalid の紹介記事を書きました。 githubのREADMEがあまりにも適当なので、使い方を補完させていただきました。応用的な使い方では様々な機能を紹介しましたが、不要なものは import しなければ tree-shaking が効くはずなので、自分が必要なものだけを import するように使っていただければと思います。 以上となります、読んでいただきありがとうございました!