LoginSignup
0
0

More than 3 years have passed since last update.

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

Posted at

はじめに

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 くらい減りました。最低限の型バリデーションだけが必要なときには悪くないかもしれません。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0