はじめに
JavaScript/TypeScript で、ネットワークから取得した JSON の型をバリデーションすることがあると思います。たとえば @hapi/joi を使うとこんな感じです。
import Joi from '@hapi/joi';
const json = '{ "age": 17, "name": "Alice" }';
const { error, value } = Joi.object({
age: Joi.number().required(),
name: Joi.string().required(),
}).validate(JSON.parse(json));
if (!error) {
// 'value' は 'json' をパースした結果で、その型は '{ age: number; name: string; }'
} else {
// 型のバリデーションに失敗
}
このコードは JavaScript であればよいのですが、TypeScript では不満があります。それはスキーマ Joi.object({ age: Joi.number().required(), name: Joi.string().required() }
によるバリデーションに成功した時点で、value
の型が { age: number; name: string; }
であることは明白なのに、TypeScript 上では any
のままになることです。これでは型システムの恩恵を受けられません。
// 'value' は 'json' をパースした結果で、その型は '{ age: number; name: string; }' のはずだが、
// TypeScript 上の型は 'any'
value.age = '37'; // 型が合っていないが、コンパイルエラーにはならない
console.log(value.namae); // typo だが、コンパイルエラーにはならない
解決のためには、わざわざ value
に分かりきった型注釈を加える必要があります。
@hapi/joi を使う場合に限らず、TypeScript 上で JSON の型のバリデーションをする場合、スキーマの定義と型の定義とで二度手間になるものが多い印象です (ツールでスキーマまたは型定義を生成するものを含め)。TypeScript 上で自動で型を導出してくれるライブラリは少ないです。
自分のユースケースでは本格的なバリデーションライブラリは必要なく、型チェックだけできればよいので、自動で型を導出するものを自分で書くことにしました。
作ったもの
Poi
https://github.com/iorate/poi-ts
https://www.npmjs.com/package/poi-ts
名前は Joi から何となく思いつきで Poi としました。Pretty Joi とか Plain Object Inspector とか。見た目も似せています。
こんな感じに使います。
import * as Poi from 'poi-ts';
const json = '{ "age": 17, "name": "Bob" }';
const value = JSON.parse(json);
// ここでは 'value' の型は 'any' だが...
try {
Poi.validate(value, Poi.object({ age: Poi.number(), name: Poi.string() }));
// ここで 'value' の型が '{ age: number; name: string; }' になる!
} catch (error) {
// 型のバリデーションに失敗
}
型を明示していないにもかかわらず、validate()
を呼び出した後から、value
の型が { age: number; name: string; }
に変化しています。確かめてみましょう:
// ここで 'value' の型が '{ age: number; name: string; }' になる!
value.age = '37'; // error TS2322: Type '"37"' is not assignable to type 'number'.
console.log(value.namae); // error TS2551: Property 'namae' does not exist on type
// '{ age: number; name: string; }'. Did you mean 'name'?
型のバリデーションに失敗した場合は、例外が投げられます。
const value = [23, 'str'];
try {
Poi.validate(value, Poi.array(Poi.number()));
} catch (error) {
if (error instanceof Poi.ValidationError) {
console.error(error.message); // 'value' is not of type 'number[]'
// 'value[1]' is not of type 'number'
}
}
また、Poi.object()
のような式 (validator) から型を取り出したい場合は、ValidatorType<>
を使います。自分で型を書き下す必要はありません。
const personValidator = Poi.object({ age: Poi.number(), name: Poi.string() });
type Person = Poi.ValidatorType<typeof personValidator>;
// type Person = { age: number; name: string; }; と同じ
実装
TypeScript 3.7 で導入された assertion functions を使用しています。関数を呼び出しただけで変数の型が変わるというクールな機能です。詳しくはこちらの記事が勉強になります。
TypeScript 3.7の`asserts x is T`型はどのように危険なのか
Poi の関数 validate()
の型と実装は次のようになっています。
export function validate<T>(
value: unknown,
validator: Validator<T>,
expression = 'value',
): asserts value is T {
const error = validator._validate(value, expression);
if (error) {
throw error;
}
}
Validator<T>
型をもつ validator
が、value
の型が T
かどうかを検証し、エラーが返らなければ、value
の型は T
であると宣言されます。
Validator の方は、たとえば number()
であれば次のように実装されています。
export function number(): Validator<number> {
return {
_typeName: 'number',
_validate(value, expression) {
if (typeof value !== 'number') {
return new ValidationError(expression, this._typeName);
}
return null;
},
};
}
number()
の型は Validator<number>
となっており、値の型が number
かどうかを検証する validator であることを示しています。
工夫したこと
オブジェクト型の optional property の実装です。すなわち以下のコードにおいて:
Poi.validate(
value,
Poi.object({
a: Poi.boolean(),
b: Poi.optional(Poi.number()),
c: Poi.string(),
}),
);
value
の型が { a: boolean; b?: number; c: string; }
になるようにします。
プロパティを optional かどうかで 2 分割して統合すればよさそうですが ({ a: boolean; c: string; } & { b?: number; }
の形にもっていく)、プロパティの順番が崩れてしまいます。一旦全てばらすしかありません (たぶん)。
export function object<VM extends Readonly<Record<string, ValidatorBase>>>(
validatorMap: VM,
): Validator<
{ // ↓ VM[K] が optional かどうかの判定
[K in keyof VM]: VM[K]['_typeKind'] extends 'optional' | undefined
? { [K0 in K]?: ValidatorType<VM[K0]> }
: { [K0 in K]: ValidatorType<VM[K0]> };
}[keyof VM] extends infer U
? (U extends unknown ? (u: U) => void : never) extends (i: infer I) => void
? { [K in keyof I]: I[K] }
: never
: never
>;
上記の例であれば、一旦全てばらして { a: boolean; } | { b?: number; } | { c: string; }
という型にした後、union を intersection に反転 (?) させるテクニック で { a: boolean; } & { b?: number; } & { c: string; }
に直しています。
できなかったこと
タプル型の optional elements と rest elements への対応。[boolean, number?, ...string[]]
みたいな型です。オーバーロードごり押し以外でできる気がしませんでした。
Variadic Tuple Types が来ればあるいは?
まとめ
TypeScript で JSON の型をバリデーションするライブラリを書きました。
とりあえず使えそうなので、さっそく 自分のブラウザ拡張機能 にも入れてみました。@hapi/joi から変えたらバンドルサイズが 150KB くらい減りました。最低限の型バリデーションだけが必要なときには悪くないかもしれません。