TypeScript で JSON の型をバリデーションする

# はじめに
JavaScript/TypeScript で、ネットワークから取得した JSON の型をバリデーションすることがあると思います。たとえば [@hapi/joi](https://github.com/hapijs/joi) を使うとこんな感じです。

```javascript
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` のままになることです。これでは型システムの恩恵を受けられません。

```typescript
  // '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 とか。見た目も似せています。

こんな感じに使います。

```typescript
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; }` に変化しています。確かめてみましょう:

```typescript
  // ここで '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'?
```

型のバリデーションに失敗した場合は、例外が投げられます。

```typescript
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<>` を使います。自分で型を書き下す必要はありません。

```typescript
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\`型はどのように危険なのか](https://qiita.com/uhyo/items/b8d2ea6fbf6214fc4194)

Poi の関数 `validate()` の型と実装は次のようになっています。

```typescript
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()` であれば次のように実装されています。

```typescript
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 の実装です。すなわち以下のコードにおいて:

```typescript
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; }` の形にもっていく)、プロパティの順番が崩れてしまいます。一旦全てばらすしかありません (たぶん)。

```typescript
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 に反転 (?) させるテクニック](https://qiita.com/suin/items/93eb9c328ee404fdfabc) で `{ a: boolean; } & { b?: number; } & { c: string; }` に直しています。

## できなかったこと
タプル型の optional elements と rest elements への対応。`[boolean, number?, ...string[]]` みたいな型です。オーバーロードごり押し以外でできる気がしませんでした。

[Variadic Tuple Types](https://qiita.com/uhyo/items/7e31bbd93a80ce9cec84) が来ればあるいは?

# まとめ
TypeScript で JSON の型をバリデーションするライブラリを書きました。

とりあえず使えそうなので、さっそく [自分のブラウザ拡張機能](https://github.com/iorate/uBlacklist) にも入れてみました。@hapi/joi から変えたらバンドルサイズが 150KB くらい減りました。最低限の型バリデーションだけが必要なときには悪くないかもしれません。