io-tsの検証結果を検証元オブジェクトと同じ構造にして利用しやすくする
記事の対象
- io-tsでバリデーションや型ガードを行っている方
- fp-tsの基本的な使い方(関数合成など)を理解されている方
pipe関数やOption型などio-ts、fp-tsの基本的な部分は、以下で解説を行いません。
@kalzit様のfp-tsでTypeScriptでも関数型プログラミングなどの記事を参照してください。
背景
- io-tsでオブジェクトを検証する際、エラーが発生したプロパティを特定したい。
- 例)フォームのバリデーション、型の検証など
- io-tsには、発生したエラーを出力する
PathReport.report関数が実装されている。- しかし、この関数は結果を文字列の配列で返すため、エラーの発生したプロパティを特定しにくい。
- 文字列をパースして特定する方法もあるが、エラーメッセージが変更された際に機能しなくなる恐れがある。
{
a: "aのエラーメッセージ",
b: "bのエラーメッセージ"
}
- 型ガードのためにはユーザ定義型ガードを都度定義する必要がある。
-
isRight(codec.decode(x))のラッパー関数を定義しなければならない。
-
解決策
以下を実装することでio-tsによるバリデーションを改善する。
- 独自のレポート関数
- 今回はRemixにおける
FormDataの検証が目的だったため、ネストされたオブジェクトは考慮しない。
- 今回はRemixにおける
- ジェネリクスを活用したユーザ定義型ガードのヘルパー関数
- 上記の関数をまとめたユーティリティクラス
環境
- Node.js@14.19.3
- npm@8.12.2
- ts-node@10.8.1
- @types/node@18.0.0
- typescript@4.7.4
- fp-ts@2.12.1
- io-ts@2.2.16
npm init
npm i fp-ts io-ts
npm i -D typescript ts-node @types/node
npx tsc --init
npx ts-node index.ts
ライブラリのインポート
import { array, option } from "fp-ts";
import { fold, isRight } from "fp-ts/lib/Either";
import { pipe } from "fp-ts/lib/function";
import { Option } from "fp-ts/lib/Option";
import { not } from "fp-ts/lib/Predicate";
import { isEmpty } from "fp-ts/lib/string";
import * as t from "io-ts";
エラーの発生したプロパティを特定する
io-tsの公式ドキュメントにヒントがあった。
const getPaths = <A>(v: t.Validation<A>): Array<string> => {
return pipe(
v,
fold(
(errors) => errors.map((error) => error.context.map(({ key }) => key).join('.')),
() => ['no errors']
)
)
}
console.log(getPaths(User.decode({}))) // => [ '.userId', '.name' ]
引数としているValidationオブジェクトは、codec.decode(x)の返り値のオブジェクトであり、型は以下のように定義されている。
export declare type Validation<A> = Either<Errors, A>
この型は正常であればRight<A>型、例外であればLeft<Errors>型となる。今回重要なのはLeft<Errors>である。Errors型はValidationError[]型であり、ValidationError型にエラーの発生したプロパティの情報が含まれている。公式ドキュメントの例では、各ValidationErrorのcontextからプロパティのキーを取り出し、joinメソッドで結合させている。
またValidationError型には、message?: stringというプロパティが存在するため、上記で取得したキーとメッセージを組み合わせることで、検証元オブジェクトと同じキーを持ったRecord型のエラーレポートを生成することができる。
エラーレポートを生成する関数を実装する
const getPath = (error: t.ValidationError) =>
error.context
.map(({ key }) => key)
.filter(not(isEmpty))
.join(".");
const getReportRecord = <A>(
v: t.Validation<A>
): Partial<Record<string, Option<string>>> => {
return pipe(
v,
fold(
array.map(
(x) =>
[
getPath(x),
pipe(x.message ?? "", option.fromPredicate(not(isEmpty))),
] as const
),
() => []
),
Object.fromEntries
);
};
getPath関数
- 公式ドキュメントの例のようなキー(パス)を生成するヘルパー関数
- 今回は基本的にネストしていないオブジェクトを前提としているが、一応ネストされていた場合も考慮し、
.で結合する。 -
contextオブジェクトには、空のキーを持つオブジェクトも存在していたため、filter関数を通している。
- 今回は基本的にネストしていないオブジェクトを前提としているが、一応ネストされていた場合も考慮し、
getReportRecord関数
-
getReportRecord関数は、今回の目的の1つであるエラーレポート生成関数-
ValidationオブジェクトがLeftであった場合、上記のgetPath関数の結果とエラーメッセージをタプルとし、Object.fromEntriesでRecord型のオブジェクトを生成する。 -
ValidationオブジェクトがRightであった場合は、Object.fromEntriesに空のリストを渡し、空のオブジェクトを生成する。
-
エラーレポートの型定義
-
Partial<Record<string, Option<string>>>という型定義にした理由は以下である。- エラーの発生していないプロパティは、エラーレポートに含まれないため、プロパティの存在チェックを義務付ける必要があった。
- エラーメッセージが
message?: stringと省略可能なプロパティであるため。- io-tsビルトインのコーデックは、メッセージが省略されている。
- エラーが発生していない場合と、エラーが発生したがメッセージが存在していない場合を区別する必要があった。
-
エラーなし→
undefined -
エラーありメッセージなし→
None -
エラーありメッセージあり→
Some<String>
- エラーレポートを利用する際、都度存在チェックと
Option型の検証を行うことは手間であるため、下記の検証で利用しているviewErrorMessage関数のようなヘルパー関数を定義することを推奨。
io-tsを用いたユーザー定義型ガードのヘルパー関数
ジェネリクスを用いて、シンプルに実装できる。
const createTypeChecker =
<T>(codec: t.Type<T>) =>
(x: unknown): x is T =>
isRight(codec.decode(x));
io-tsのビルトインもしくはtype関数で定義した独自のコーデックを引数とし、型ガード関数を返す高階関数とした。
io-tsのバリデーションユーティリティクラス
これまでの機能を1つのクラスにまとめ利便性を高める。
class Validator<A extends t.Props> {
private _codec: t.TypeC<A>;
private _validation: t.Validation<t.TypeOf<t.TypeC<A>>> | undefined;
constructor(codec: t.TypeC<A>) {
this._codec = codec;
}
validate = (x: unknown): x is t.TypeOf<t.TypeC<A>> => {
this._validation = this._codec.decode(x);
return isRight(this._validation);
};
get error() {
if (this._validation) return getReportRecord(this._validation);
else return {};
}
}
コンストラクタ
io-tsのコーデックを受け取って、フィールド変数に格納する。
validateメソッド
バリデーションの結果をboolean型で返す関数であり、同時にユーザー定義型ガードとしても機能する。エラーレポートで使用するため、Validationオブジェクトを格納する必要があり、上記の高階関数を使用せず別途定義した。
errorゲッター
最新の検証結果(this._validation)からエラーレポートを生成して返すゲッター。検証を一度も行っておらず、this._validationがundefinedの場合は空のオブジェクトを返す。
動作確認
以下のようなテストを実行し、正常に動作しているか検証する。
withMessage関数は、io-ts用のユーティリティをまとめたio-ts-typesライブラリの関数である。メッセージが設定されていないビルトインのコーデックを用いたため、この関数でメッセージがあった場合の動作を再現する。
viewErrorMessage関数は、エラーレポートを表示用に加工する関数である。エラーレポートを利用するたびに、存在チェックとOption型の判定をするのは手間がかかるため、関数を定義し、メッセージの初期値などを設定すると利用しやすくなる。
const viewErrorMessage = (
message: Option<string> | undefined,
empty = "error"
): string | undefined => {
if (message) {
return pipe(
message,
foldW(
() => empty,
(x) => x
)
);
}
return message;
};
const records = [
{
a: "",
b: 1,
},
{
a: undefined,
b: "1",
},
];
const main = () => {
const codec = t.type({
a: t.string,
b: withMessage(t.number, () => "not number"),
});
const typeChecker = new Validator(codec);
records.forEach((x) => {
console.log(x);
console.log(typeChecker.validate(x));
console.log(typeChecker.error);
console.log(record.map(viewErrorMessage)(typeChecker.error));
console.log("\n");
});
};
main();
// 正しい形式のオブジェクト
{ a: '', b: 1 }
true
{}
{}
// 誤った形式のオブジェクト
{ a: undefined, b: '1' }
false
// メッセージがOption型で、中身がない場合None
{ a: { _tag: 'None' }, b: { _tag: 'Some', value: 'not number' } }
{ a: 'error', b: 'not number' }
以上の検証結果から意図したとおりに動作していることがわかった。
最後に
以上がio-tsの検証結果を活用しやすくするクラスの定義でした。おおよそやりたいことは実現できましたが、いくつか改良しなければならない点があることはご了承ください。
- 例)ネストされたオブジェクトに対応していない。
// ネストされたコーデック
type({
a: type({
b: string
})
})
// 検証結果の例
// {
// "a.b": "error"
// }
io-tsは非常に利便性の高いライブラリであるのですが、日本ではあまり流行っていない(バリデーションにはzodなどのほうが使われている?)のか、日本語の記事はあまり見受けられませんでした。しかし、バリデーションに関しては他にもライブラリはありますが、io-tsは関数型プログラミング支援ライブラリfp-tsと合わせて使うことで、高機能なfp-tsの機能を活用できます。個人的にはかなり気に入ったライブラリなので、もっと利用者が増えていけばいいなと思っています。
参考資料
公式ドキュメント
fp-tsでTypeScriptでも関数型プログラミング
コード全体
検証のためimport { withMessage } from "io-ts-types";を加えています。
import { array, option, record } from "fp-ts";
import { fold, isRight } from "fp-ts/lib/Either";
import { pipe } from "fp-ts/lib/function";
import { foldW, Option } from "fp-ts/lib/Option";
import { not } from "fp-ts/lib/Predicate";
import { isEmpty } from "fp-ts/lib/string";
import * as t from "io-ts";
import { withMessage } from "io-ts-types";
const getPath = (error: t.ValidationError) =>
error.context
.map(({ key }) => key)
.filter(not(isEmpty))
.join(".");
const getReportRecord = <A>(
v: t.Validation<A>
): Partial<Record<string, Option<string>>> => {
return pipe(
v,
fold(
array.map(
(x) =>
[
getPath(x),
pipe(x.message ?? "", option.fromPredicate(not(isEmpty))),
] as const
),
() => []
),
Object.fromEntries
);
};
export const viewErrorMessage = (
message: Option<string> | undefined,
empty = "error"
): string | undefined => {
if (message) {
return pipe(
message,
foldW(
() => empty,
(x) => x
)
);
}
return message;
};
class TypeChecker<A extends t.Props> {
private _codec: t.TypeC<A>;
private _validation: t.Validation<t.TypeOf<t.TypeC<A>>> | undefined;
constructor(codec: t.TypeC<A>) {
this._codec = codec;
}
validate = (x: unknown): x is t.TypeOf<t.TypeC<A>> => {
this._validation = this._codec.decode(x);
return isRight(this._validation);
};
get error() {
if (this._validation) return getReportRecord(this._validation);
else return {};
}
}
const records = [
{
a: "",
b: 1,
},
{
a: undefined,
b: "1",
},
];
const main = () => {
const codec = t.type({
a: t.string,
b: withMessage(t.number, () => "not number"),
});
const typeChecker = new TypeChecker(codec);
records.forEach((x) => {
console.log(x);
console.log(typeChecker.validate(x));
console.log(typeChecker.error);
console.log(record.map(viewErrorMessage)(typeChecker.error));
console.log("\n");
});
};
main();