やりたいこと
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
の型を間違ってしまった場合
ちゃんと「false
がnumber
に代入できないよ」とコンパイル時に検出できます。
age
を書き忘れちゃったとき
ちゃんと「プロパティageが見つからないよ」とコンパイル時に検出できます。
存在しないプロパティを書いてしまったとき
ちゃんと「'somethingElse'は存在しないよ」とコンパイル時に検出できます。
配列/入れ子/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
を作る話など、詳しい実装は後述します。
以下のように、Literal Typeの間違いも検出します。
以下のように、[string, boolean, number]
のタプルの真ん中の型を間違えると、これもコンパイル時に検出できます。
プロパティmyStrs
を消してみると、以下のように、「'myStrs' is declared here」と言われ、たりないことがわかります。
他にも色々と試してみて、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
です。そこでT
にString
を当てはめると、
(Array<String>["tsType"])
=== (String['tsType'])[]
になります。
export interface Array<T extends Json> {
tsType: T['tsType'][];
runtimeType: {
base: 'array',
element: JsonRuntimeType
};
}
最後に、(String['tsType'])[]
のString['tsType']
を求めます。以下がString
の定義です。tsType
はstring
です。そのため、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を集めた型になります。これでオプショナルなプロパティとそうでないものを分けています。