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();