TypeScriptでのバリデーションにsuperstructが最高
TypeScript に関わらずバリデーションを書く場面というのは非常に多いと思います。例えば、フォーム・API に POST された HTTP Body の検証等、場面は変わってもやることはあまり変わらないと思います。愚直に if 文を書く場面も多いかと思うのですが、TypeScript のライブラリsuperstructを利用すると記述をシンプルに行なえます。
サンプルリポジトリ: https://github.com/kamijin-fanta/react-form-schemavalidation-example
↑ 雑なスライドです。この記事も同様の内容を記述しています。
Data Structure Validation
- フィールド型・データの範囲などを定義・検証する仕組み
- 人間が検証コードを書く必要が無くなって良い
- 仕様例
- JSON Schema
- Swagger
- 実装例: go, python 等様々な言語にてライブラリが存在
JSON Schema で、2 ~ 3 文字の string というルールを書いた例
{
"type": "string",
"minLength": 2,
"maxLength": 3
}
superstruct
色々仕様・実装が有るわけですが、TypeScript でのみ利用するのであれば superstruct がかなり使いやすいかなと感じます。
- 公式ドキュメント: https://docs.superstructjs.org/
- サンプルコード: https://github.com/kamijin-fanta/react-form-schemavalidation-example/blob/master/src/superstruct/basic.test.ts
まずは簡単な例から。 assert 関数はルールに一致しなければ例外を吐きます。 TypeScript のassertion functionとして利用が可能なので、言語との親和性も高いです。
it("string & assert", () => {
const strSchema = string(); // 文字列というスキーマを作成
{
const input: any = "1234";
assert(input, strSchema);
// ここまで到達すればstringであることが保証される
expect(typeof input).toEqual("string");
}
expect(() => {
const input: any = 1234;
assert(input, strSchema);
}).toThrow("Expected a string, but received: 1234");
});
例外を投げてほしくない場合、以下のようにも検証が可能です。
assert
関数の代わりに validate
関数を利用しています。
タプルでエラーと結果が返ります。
it("number & validation", () => {
const numSchema = number();
{
const input: any = 1234;
const [error, result] = validate(input, numSchema); // numSchema.validate(input)とも書ける
expect(error).toBeUndefined();
expect(result).toEqual(1234);
}
{
const input: any = "1234";
const [error, result] = validate(input, numSchema);
expect(error?.message).toEqual('Expected a number, but received: "1234"');
expect(result).toBeUndefined();
}
});
次はオブジェクトの構造について検証します。
数値のid
、文字列のname
、文字列を含む配列のtags
を定義しています。
ここでは検証にassert
やvalidate
の代わりにis
を利用しているので、型のガードが行われます。
it("object & is", () => {
const userSchema = object({
id: number(),
name: string(),
tags: array(string()),
});
{
const input: any = { id: 123, name: "mike", tags: ["hoge", "fuga"] };
expect(is(input, userSchema)).toEqual(true);
if (is(input, userSchema)) {
// inputがuserSchemaを満たす場合にのみifに入るので、inputはanyでなくなる
// input.id, input.name...
}
}
{
const input: any = { name: "mike" };
expect(is(input, userSchema)).toEqual(false);
}
});
今まではデータ型について注目していましたが、内容についてバリデーションを書きます。
name
フィールドは長さが 1~20 の文字列であることが必要です。
また、Infer
型を用いてuserSchema
からuserType
の導出も行ってみました。
size
以外にもいくつかの制約条件がデフォルトで定義されており、自分で定義することも可能です。
it("refinements", () => {
const userSchema = object({
id: number(),
name: size(string(), 1, 20),
});
type userType = Infer<typeof userSchema>;
{
const input: userType = { id: 123, name: "" };
const [error] = validate(input, userSchema);
expect(error?.message).toEqual(
"At path: name -- Expected a string with a length between `1` and `20` but received one with a length of `0`",
);
}
});
フィールド内容によってその値を書き換える機能も提供されており、例えばからの場合にデフォルト値を設定することが可能です。
validate
関数のオプションに{ coerce: true }
を指定することで利用が可能です。
ここではデフォルト値として、ID の値を実行するたびに増加する整数を設定しています。
it("coercions", () => {
let index = 0;
const userSchema = object({
id: defaulted(number(), () => index++),
name: trimmed(size(string(), 1, 20)),
});
{
const input: any = { name: " mike " };
// 1回目の実行
const [, res1] = validate(input, userSchema, { coerce: true });
expect(res1).toEqual({
id: 0,
name: "mike",
});
// 2回目の実行
const [, res2] = validate(input, userSchema, { coerce: true });
expect(res2).toEqual({
id: 1,
name: "mike",
});
}
});
今までは新たにスキーマから作成していましたが、既存の型に対してのスキーマを作成することも可能です。
Describe<T>
型を用いることで、スキーマと既存型が一致していることを検証できます。
残念ながら自分で内容は埋めなければならないのですが、抜け漏れを防ぐことが出来ます。
it("use exist type", () => {
type User = {
id: number;
name: string;
};
const userSchema: Describe<User> = object({
id: number(),
name: string(),
});
const input: any = { id: 123, name: "mike" };
const [, res] = validate(input, userSchema);
expect(res).toEqual({
id: 123,
name: "mike",
});
});
react-hook-form と組み合わせる
react-hook-form はパフォーマンスが良く使いやすい React のフォームライブラリです。 バリデーション・繰り返しフィールド等の機能が一通り揃っていて実用的です。
- 公式サイト: https://react-hook-form.com/
- サンプルコード: https://github.com/kamijin-fanta/react-form-schemavalidation-example/blob/master/src/simple-form/simple-form.tsx
通常 react-hook-form では次のようにフォームを記述します。 バリデーションのルールは register の中に記述されています。
export function SimpleForm(): React.ReactElement {
const { register, handleSubmit, errors } = useForm();
const onSubmit = (data: any) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="group">
<label>
name
<input
name="name"
ref={register({ required: true, minLength: 1, maxLength: 20 })}
/>
{errors.name && "name is required"}
</label>
</div>
<div className="group">
<label>
age
<input
name="age"
type="number"
ref={register({ required: true, valueAsNumber: true, min: 20 })}
/>
{errors.age && "age must be upper 20"}
</label>
</div>
<input type="submit" />
</form>
);
}
こちらの書き方でも良いのですが、エラー文章を自分で書く必要があり、errors
onSubmit
が型安全とはなりません。
(もちろん自分で型注釈すれば改善できます)
こういった問題を superstruct を組み合わせることで改善できます。
superstruct を利用した例は以下のようになります。
const parsableInt = coerce(number(), string(), (value) => parseInt(value));
const userSchema = object({
name: size(string(), 1, 20),
age: min(parsableInt, 20),
});
type userType = Infer<typeof userSchema>;
export function SchemaValidationForm(): React.ReactElement {
const { register, handleSubmit, errors } = useForm({
resolver: superstructResolver(userSchema, { coerce: true }),
});
const onSubmit = (data: userType) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="group">
<label>
name
<input name="name" ref={register()} />
{errors.name?.message}
</label>
</div>
<div className="group">
<label>
age
<input name="age" type="number" ref={register()} />
{errors.age?.message}
</label>
</div>
<input type="submit" />
</form>
);
}
register 内のルール記述が不要となり、エラーメッセージは superstruct が自動生成します。
errors
等もスキーマから自動的に型の推測が行われるので、typo 等の心配もなくなります。
react-hook-form+superstruct は react でフォーム作る人にオススメな構成だと言えるでしょう。