Edited at

JSONをTypeScriptの型でバリデーションしたい!


やりたいこと

JSONのTypeScriptの型でバリデーションしたいです。定義するコストは最小限にとどめたいです。

具体的には、

文字列になった'{"name": "jack", "age": 8}'などがtype Person = {name: string, age: number}をちゃんと満たしているかを確かめたいです。確かめたあとは、Person型として扱いたいです。

JSONはネットワークから飛んできたりして、それがTypeScriptの型システムの中で安全に使えるか確かめる仕組みがほしいです。

一番この記事で伝えたのは、JSONの構造を一度定義したら、TypeScriptの型を導出する仕組みです。いろんなTypeScriptのバリデーションライブラリ調べてみましたが、型を自動で導出しているものは見つけらなかったです。そのため、その仕組を作って、使い方と実装に関して記事にまとめました。


なぜJSONを型でバリデーションしたいのか?

JSON.parse()anyを返してしまうからです。

anyのため、戻り値をどんな型にも代入可能です。プロパティが存在してなくても代入可能です。これでは、存在しないプロパティのアクセスしたときのエラーは実行時に出てしまいます。また、parse()の戻り値がプロパティの型が違っていても代入できてしまいます。せっかくのTypeScriptの型システムを活かせず、安全じゃないです。


理想の型のバリデーション

一番の理想は、型定義が与えられ、それだけでJSONをバリデーションすることです。

そのためには、TypeScriptの型を実行時に知れる必要があります。

なぜなら、バリデーションは実行時に行われるからです。実行時にJSONがその型に準拠しているかを検証することで型によるバリデーションが達成できます。

ただ、どうやらTypeScriptのポリシー的にそれが可能ではなそうなのです。

バリデーションライブラリの一つであるjson-type-validationでTypeScriptではランタイムでの型情報の提供がNon-goalsになっていると教えてくれます。おそらく以下の文章が指しているnon-goalだと思います。


Add or rely on run-time type information in programs, or emit different code based on the results of the type system. Instead, encourage programming patterns that do not require run-time metadata.


(引用元: https://github.com/Microsoft/TypeScript/wiki/TypeScript-Design-Goals#non-goals)

型システムの結果によって異なったコードが出来上がるようなことは、TypeScriptはしたくないようです。そのため、実行時にTypeScriptの型を知ることができず、実行時に型によるJSONのバリデーションができないです。


型バリデーションをする解決策

解決策は、json-type-validationがやっている方法になると思います。このライブラリでは型情報用のオプジェクトを作って、それを使ってバリデーションします。

ただ、ドキュメントを見る限り、型情報用のオブジェクト(Decoder)と型定義の2つを書く必要がありそうです。

今回は、型の構造を書けば静的に型を生成する仕組み+バリデーションを作ったので紹介したいと思います。


GitHubリポジトリ

https://github.com/nwtgck/ts-json-validator


使い方

以下でGitHub経由でインストールできます。

npm install -S git+https://github.com/nwtgck/ts-json-validator#v0.1.2

(もう少し個人的に使ってみてから、npmに上げたいなと思ってます)

TypeScript 3.4.5を使ってます。

// 型バリデーション用のJSONのフォーマットの定義

const personFormat = obj({
name: str,
age: num
});

// 型のフォーマットからPerson型を導出
// (ここが一番のポイント!)
type Person = TsType<typeof personFormat>;

// JSON文字列からPersonオブジェクトを作成
// JSON.parse()より安全にパース
const p1: Person | undefined = validatingParse(personFormat, '{"name": "jack", "age": 8}');
// => {name: "jack", age: 8}

一番の重要なのが、type Person = TsType<typeof personFormat>です。変数personFormatを作れば、それに対応する型を導出できるところです。

この場合、以下のようにPerson型が導出されます。

// (自動でコンパイラが導出してくれる)

// (自分で定義する必要はありません)
type Person = {
name: string,
age: number
}

調べた範囲のTypeScript用のバリデーションライブラリは、ランタイムのためのJSONの構造情報と型定義の2つを作成する必要がありました。

保守のしやすさを考えると、どちらか一つで済むと嬉しいです。

interfaceが欲しい場合は、以下のようにするとできると思います。

interface IPerson extends TsType<typeof personFormat> {}


型安全なの?

導出したPerson型がちゃんと思ったとおりに導出できているか、確認します。


ageの型を間違ってしまった場合

ちゃんと「falsenumberに代入できないよ」とコンパイル時に検出できます。

image.png


ageを書き忘れちゃったとき

ちゃんと「プロパティageが見つからないよ」とコンパイル時に検出できます。

image.png


存在しないプロパティを書いてしまったとき

ちゃんと「'somethingElse'は存在しないよ」とコンパイル時に検出できます。

image.png


配列/入れ子/Literal Type/Union Type/タプルなどなど

色んな型を使う例です。

言葉の説明はコード内のコメントに書いています。

// 型のフォーマットを定義

const myObj1Format = obj({
// 文字列配列 - string[]
myStrs: arr(str),
// 入れ子のオブジェクト - (myFlag: boolean)
myObj: obj({
// 真偽値
myFlag: bool
}),
// Union Type - string | number | bool
strOrNumOrBool: union(str, num, bool),
// オブジェクト配列 - {prop1: number, prop2: string}[]
myObjs: arr(obj({
prop1: num,
prop2: str
})),
// なくても良いプロパティ(myOptional?: number)
myOptional: opt(num),
// Literal Type - (myLiteral: 'MY_LITERAL')
myLiteral: literal('MY_LITERAL' as const),
// Literal TypeのUnion - ('red' | 'blue')
myLitUnion: union(literal('red' as const), literal('blue' as const)),
// null許容 - (string | null)
myNullable: union(str, nul),
// タプル - [string, boolean, number]
myTuple: tuple(str, bool, num),
});

// 型を導出する
type MyObj1 = TsType<typeof myObj1Format>;

interfaceがほしいときは、以下のようにすると良いと思います。

interface IMyObj1 extends TsType<typeof myObj1Format> {}

以下のように、MyObj1型の変数を作れます。

const myObj1: MyObj1 = {

// 文字列配列 - string[]
myStrs: ['apple', 'orange'],
// 入れ子のオブジェクト - (myFlag: boolean)
myObj: {
// 真偽値
myFlag: true
},
// Union Type - string | number | bool
strOrNumOrBool: 'this is a text',
// オブジェクト配列 - {prop1: number, prop2: string}[]
myObjs: [
{prop1: 9, prop2: 'abc'},
{prop1: 43, prop2: 'hello'},
],
// なくても良いプロパティ(myOptional?: number)
// myOptional: 43, // なくても良いからコメントアウトできる
// Literal Type - (myLiteral: 'MY_LITERAL')
myLiteral: 'MY_LITERAL',
// Literal TypeのUnion - ('red' | 'blue')
myLitUnion: 'blue',
// null許容 - (string | null)
myNullable: null,
// タプル - [string, boolean, number]
myTuple: ['my string', false, 32],
};


コンパイラで間違いが検出できる確かめる

以下のように、prop1にわざと文字列を入れると、ちゃんと「stringがnumberに代入できない」とわかります。IDEでの赤波線がprop1に出るようにTsTypeを作る話など、詳しい実装は後述します。

image.png

以下のように、Literal Typeの間違いも検出します。

image.png

以下のように、[string, boolean, number]のタプルの真ん中の型を間違えると、これもコンパイル時に検出できます。

image.png

プロパティmyStrsを消してみると、以下のように、「'myStrs' is declared here」と言われ、たりないことがわかります。

image.png

他にも色々と試してみて、type MyObj1 = ...で型が導出できているか確認できます。


TsType<>obj以外の配列などでも使える

今までの例では、obj({...})で作った変数でTsType<>をしてきました。

場合によってはkey-valueのオブジェクト以外に、配列をつかいたときなどもあると思います。(APIによってはJSONの配列が返ってくることもよくあります。)

TsType<>の使い方は変わらず、以下のように使えます。

以下は簡単のためstring[]ですが、オブジェクトの配列にしたり、好きにできます。TsType<>の実装などは後述です。

// string[]

const strArrayFormat = arr(str);

// string[]として導出される
type MyStringArray = TsType<typeof strArrayFormat>;

// 普通の文字列配列
const myArray: MyStringArray = ['hello, world', 'apple orange'];

その他、numでもstrでもunionでもtupleでも同様にTsType<>で純粋なTypeScriptの型をを導出できます。


実装面 - 少し型レベルの話

TsType<typeof personFormat>でどうやって自動で型が導かれるのかの話です。

これは実行時のtypeofとは違うと思います。コンパイル時に解決されるものだと思います。

以下のようにするとN型はnumber型になります。

const n: number = 10;

type N = typeof n;

TsType<...>は現在のバージョン0.1.2だと、type TsType<J extends Json> = J['tsType'];となってます。

そのため、type Person = (typeof personFormat)["tsType"]でも同じようにPerson型が取れるはずです。

この["tsType"]の記法のシンプルな例は以下のようなものです。

以下のMyType["stringType"]string型になります。

type MyType = {

stringType: string;
}
type MyString = MyType["stringType"];

type Jsonというものに対して["tsType"]しています。現在のJsonの定義は以下のUnion Typeになっています。つまり、Null["tsType"]String["tsType"]などで型が返ってくるイメージです。

export type Json =

Null |
Boolean |
Number |
String |
Literal<unknown> |
Union |
Tuple |
Optional<any> |
Array<any> |
Object<{[key: string]: Json}>;

Null["tsType"]Object<...>["tsType"]によって返ってくる型がが変わります。そのため型レベルの関数のようなイメージになります。Haskellだと型族が似たような概念なのではないかと思います。


TsType<typeof strArrayFormat> === string[]を追いかける

ちょっと前に登場したconst strArrayFormat = arr(str);TsType<typeof strArrayFormat>string[]になることを例にとって動作を追ってみます。

まずは、TsType<>["tsType"]に置き換えて、以下がなりたちます。

TsType<typeof strArrayFormat> === (typeof strArrayFormat)["tsType"]

次にtypeof strArrayFormatを求めます。typeof strArrayFormatは単純にstrArrayFormatの型です。関数arr()と変数strの型定義を見ることでわかります。

const str: String

function arr<T extends Json>(elem: T): Array<T>

ここからarr(str)Array<String>型になることがわかります。

つまり、(typeof strArrayFormat)["tsType"]===(Array<String>)["tsType"]が成り立ちます。

(Array<String>)["tsType"]を求めます。以下のArray<T>の定義のtsTypeを見ると、T['tsType'][]です。ここではT===Stringです。そこでTStringを当てはめると、

(Array<String>["tsType"]) === (String['tsType'])[]になります。

export interface Array<T extends Json> {

tsType: T['tsType'][];
runtimeType: {
base: 'array',
element: JsonRuntimeType
};
}

最後に、(String['tsType'])[]String['tsType']を求めます。以下がStringの定義です。tsTypestringです。そのため、String['tsType'] === stringです。

export interface String {

tsType: string;
runtimeType: 'string';
}

最終的に(String['tsType'])[] === string[]となります。

イコールを全部つなげると、TsType<typeof strArrayFormat> === ... === string[]となることが分かります。


tsTypeの定義

tsTypeの実装はとても自然な定義になっています。


  • 数値ならtsType: numberです

  • 配列の場合は今見たようにtsType: T['tsType'][]です。

  • 3つのUnionなら、tsType: T1['tsType'] | T2['tsType'] | T3['tsType']です。

  • ...


key-valueのオブジェクトのtsType

データ型で、一番複雑なのは、key-valueになっているオブジェクトの形式だと思います。

少し複雑にさせるのは、オプショナルなプロパティの存在の影響です。(例:{myProp?: string}

以下がその定義です。

type NonOptionalKeys<Obj extends {[key: string]: Json}> = {

[K in keyof Obj]: undefined extends Obj[K]['tsType'] ? never : K
}[keyof Obj];

type OptionalKeys<Obj extends {[key: string]: Json}> = {
[K in keyof Obj]: undefined extends Obj[K]['tsType'] ? K: never;
}[keyof Obj];

type NonOptionalObj<Obj extends {[key: string]: Json}> = Pick<Obj, NonOptionalKeys<Obj>>;
type OptionalObj<Obj extends {[key: string]: Json}> = Pick<Obj, OptionalKeys<Obj>>;

export interface Object<O extends {[key: string]: Json}> {
tsType: {
[K in keyof NonOptionalObj<O>]: O[K]['tsType'];
} & {
[K in keyof OptionalObj<O>]?: O[K]['tsType'];
};
runtimeType: {
base: 'object',
keyValues: {[key: string]: JsonRuntimeType}
}
}

OptionalKeys<>T | undefinedように、undefinedを含まないプロパティのキーの集まりになります。NonOptionalKeys<>はその補集合です。Pick<>はTypeScript標準である型です。Pick<Obj, Keys>Obj中でKeysなkey-valueを集めた型になります。これでオプショナルなプロパティとそうでないものを分けています。