RawObjectDataProcessor : 問題考察
事前に不明で、外部から来るデータの扱いは プログラミングの基本的な課題の一つ。 データの事例は、
- クライアント・—サーバーモデルに於けるクライアント側からの来るデータ
- 逆に、クライアント—・サーバーモデルに於けるサーバー側から来るのデータ
- データベースからの取得したデータ
- ファイル(JSON、YAML等)からの読み取ったデータ
其の様なデータはTypeScriptに届けられない源から来るので、 最初は型がunknownか、更に悪いと anyになっている。
一般的には此の様な状況に於いては何がされるだろうか? 残念なら、高品質のコードだと言えるやり方ではないない。 クライアント—・サーバー間の取引に於いて、クライアント側から受け取るデータの妥当性確認が セキュリティ上の必須対策と見做されているので、行われるが、其れ以外の多くの別の場合、例えば下記の様にサーバーから受け取る データを其の儘信用して望む型が付けられてしまう。
type User = {
ID: string;
familyName: string;
givenName: string;
};
fetch("http://example.com/users/1").
then((response: Response): void => response.json()).
then((data: unknown) => {
// これは悪い例。真似しないこと!!!
const user: User = data as User;
const fullName: string = `${ user.givenName } ${ user.familyName }`;
console.log(fullName);
});
「サーバーからデータを取得する時、何の為データをバリデーションする必用が有るのか?」と聞きたいなら、我が逆質問は「サーバーからデータを取得する時、 データをバリデーションする必用が無い理由は何?」。 「サーバーに100%正しいデータしかない」という回答は実戦から遠すぎると思わないだろうか? 実戦から見る、大抵の中規模以上のプロジェクトには期待データと実データの食い違いが起きがち。 特にクライアント側とサーバーが違う言語で実装されたか、別の団体で開発されている場合によく起きる事。 其の食い違いの数は数十から数百、場合に依っては千単位にも成る事が有る。 原因は単純な人為ミスから、データ変更の通知が関係エンジニアに適時届かない事迄様々。 尚、データベースへのデータ保存が複数の方法で行われ、即ちアプリケーションのGUIを介した方法や、データベースマネージャーの利用や、SQLクエリの 実行や、データのインポート等。 上記の方法に依って、データが完全なバリデーションを通っていない事が有る。 此の食い違いを可能な限り早く検出する必用が有る。
更に、ファイルに依る宣言的な設定を持っている(普通はJSONやYAML等) docker composeの様なユーティリティを作るなら、 誤った設定は発生しがちな流れなので、此の場合の妥当性確認も必須。
ライブラリ無しの方法
型ガードはTypeScriptのネイティブ機能。 型ガードはブーリアン値を返す関数だが、 戻る値のアノテーションはbooleanではなく、 x is Tの原形通り。 此処でxは型を検査しなければいけない対象引数で、 Tは望む型。
type User = {
ID: string;
familyName: string;
givenName: string;
};
function isUser(rawData: unknown): rawData is User {
return typeof rawData === "object" &&
rawData !== null &&
"ID" in rawData && rawData.ID === "string" &&
"familyName" in rawData && rawData.familyName === "string" &&
"givenName" in rawData && rawData.givenName === "string";
}
公式のTypeScriptドキュメントの他、型ガードはフロントエンドエンジニアの Marius Schulzの記事で丁寧に解説されている。 今我々にとって重要な事は、
trueを返した事は、実型は希望型と合っている事を意味している訳では なく、TypeScriptに対して合っている事を信じる頼みに過ぎない。 事実上、型ガードの体に行われている処理は引数は希望型に該当してるか、という確認に完全に関係無い事がTypeScriptには 検出・予防不可能。下記は態と間違っている例。 此れに於いてisUserガードは引数が User型になっているか、検問するべきはずだが、事実上 User型に関係無いプロパティーを確認している。
type User = {
ID: string;
familyName: string;
givenName: string;
};
// 態と誤った例!!!
function isUser(rawData: unknown): rawData is User {
return typeof rawData === "object" &&
rawData !== null &&
"title" in rawData && rawData.title === "string" &&
"price" in rawData && rawData.price === "string";
}
const potentialUser: unknown = { title: "Shampoo", price: 1000 };
if (isUser(potentialUser)) {
console.log(potentialUser.familyName);
}
potentialUserという引数の値はUser 型と完全に関係無い のに、isUserはtrueを返し、 TypeScriptは何か可笑しいとは気付かない。 更に下記の例通り、ガード関数の本体で何も検査していなくても、 TypeScriptは不満を言わない。
type User = {
ID: string;
familyName: string;
givenName: string;
};
// 態と誤った例!!!
function isUser(rawData: unknown): rawData is User {
console.log(rawData);
return Math.random() >= 0.5;
}
何故事情が其処迄悪いだろうか。 結論から言うと、原因はTypeScriptの根本的な制約に在る。 上記の型ガードを含めて、データの妥当性確認は JavaScriptの実行時に行われ、其の時点元の TypeScriptコードが最早存在していない。 出力されたJavaScriptコードでは型ガードは既に 只の真偽値を返すJavaScript関数に過ぎない。
型エイリアス(typeキーワード)やインターフェースの様な TypeScriptの機能は元のTypeScriptコード内に しか存在せず、出力JavaScriptコードには無いので、 実行時にこれ等に参照する手段も無い。 理論上は、TypeScriptコードをJavaScriptコードへのトランスパイリングの際、 元のTypeScriptコードに有る型エイリアスやインターフェースを元に補助関数 やオブジェクトの自動生成機能を実装すると、毎回態々手動で妥当性確認コードを書かなくても、妥当性確認が行われる。 残念ながら、TypeScript開発チームが近い将来に此の様な機能を実装する可能性は低い。
上述の問題に加えて、型ガードには幾つかの重要な問題が有る。
- 型ガードは設計上単に引数が妥当かどうかを答える だけで(然も正解・不正解は実装次第)、具体的に何処で違反が起きているか、そして違反数は報告 されない。
- 型ガードは最初の違反で
falseを返すが、 他に違反がいくら有っても当然。
厳密には、上記問題は型ガードの概念に有る問題ではなく、実践的なの問題になっている。 上記通り、型ガードの実装でTypeScriptが要求するのは真偽値を返す事 と戻り型の特別な注釈という二つだけで、 任意にロギングや全プロパティの完全検問を含めて何でも実装可能。 とはいえ、実際にはそういった事が滅多にされない事であり、其れに十分な理由が有る。 初心者専用の練習問題の話しではなく、実際のプロジェクトだと、上記の機能を丁寧に実装 し始めるとボイラープレートコードが急増に膨らんで、所々ほぼ変わらない。 特に高品質のロギングに当たる事であり、同種のメッセージが大量に発生し、毎回丁寧な本文を考えて書くか、メッセージを別オブジェクトやファイルに 切り出し、やがてはライブラリ化を検討する事になる。 尚、実プロジェクトの場合、オブジェクトは上の例に出たUserの様に単純ではなく、 20〜30程度のプロパティを持つ事も普通で、より多くのも珍しくない。 更に入れ子のオブジェクトや配列(オブジェクト型 要素の配列の場合を含めて)も頻出。 ボイラープレートの大量は疲労に依る間違いを伴いがち。 故にネイティブ解決は存在してはいるが、実用的ではない。
とはいえ型ガードが無用な訳ではなく、多数のプロパティを持つ オブジェクトの検証には良くないだけだ。 他の値の型(文字列や数値等)なら適しており、実践に大量に使われる。 YDEEも型ガードを提供しており、多くはライブラリ内部でも利用されている。
RawObjectDataProcessor方法論
TypeScriptからJavaScriptへのトランスパイル際に インターフェースや型エイリアス(typeキーワード)は 消滅する為、JavaScriptコードの実行時にそれらを参照する方法は全く 無いので、特定のオブジェクトが具体的な型である事を 完全に証明するのも不可能。 たとえ専用の型ガードが有ったとしても、其の内部のロジックが必要なプロパティ検査と 全く関係ない迄有り得る。 然し、「だからasを使おう」という結論な訳ではなく、 asを使う前に、其の使用に論証が必要、即ち妥当性確認 (バリデーション)。 但し保守性の為にバリデータは実際のデータの期待のデータとの不一致を、一件目だけではなく、 皆をログしなければいけません。 此処迄対策を取ると、たとえ疲労等に依るミスでバリデーション規則に間違ったとしても、実践上其の間違いが発見させる事が早い場合は殆ど。
此の様にRawObjectDataProcessorはas使用の過失を 引き受ける代わりに、妥当なデータの仕様をほぼ宣言的に与えることを要求する。 先述の理論の観点で デモ をもう一度考察しておこう。
type SampleType = {
foo: number;
bar: string;
baz: boolean;
hoge?: number;
fuga: string | null;
quux: {
alpha: number;
bravo: "PLATINUM" | "GOLD" | "SILVER";
};
};
const validDataSpecification: RawObjectDataProcessor.ObjectDataSpecification = {
nameForLogging: "Example",
subtype: RawObjectDataProcessor.ObjectSubtypes.fixedSchema,
properties: {
foo: {
type: Number,
isUndefinedForbidden: true,
isNullForbidden: true,
numbersSet: RawObjectDataProcessor.NumbersSets.positiveIntegerOrZero,
isNaN_Forbidden: true
},
bar: {
type: String,
isUndefinedForbidden: true,
isNullForbidden: true,
minimalCharactersCount: 5
},
baz: {
type: Boolean,
isUndefinedForbidden: true,
isNullForbidden: true
},
hoge: {
type: Number,
isUndefinedForbidden: false,
isNullForbidden: true,
isNaN_Forbidden: true,
numbersSet: RawObjectDataProcessor.NumbersSets.positiveIntegerOrZero
},
fuga: {
type: Number,
isUndefinedForbidden: true,
isNullForbidden: false,
numbersSet: RawObjectDataProcessor.NumbersSets.positiveIntegerOrZero,
isNaN_Forbidden: true
},
quux: {
type: Object,
isUndefinedForbidden: true,
isNullForbidden: true,
properties: {
alpha: {
type: Number,
isUndefinedForbidden: true,
isNullForbidden: true,
numbersSet: RawObjectDataProcessor.NumbersSets.anyInteger,
isNaN_Forbidden: true,
minimalValue: 3
},
bravo: {
type: String,
isUndefinedForbidden: true,
isNullForbidden: true,
minimalCharactersCount: 5,
allowedAlternatives: [ "PLATINUM", "GOLD", "SILVER" ]
}
}
}
}
};
function onDataRetrieved(externalData: unknown): void {
const externalDataProcessingResult: RawObjectDataProcessor.ProcessingResult<SampleType> = RawObjectDataProcessor.
process(externalData, validDataSpecification);
}
- 先ずRawObjectDataProcessorが
externalDataが オブジェクトかどうかを確認する。 オブジェクトになっていなければ、今後検証・処理するものは無い。 次にRawObjectDataProcessorが
externalDataオブジェクトの中で妥当なデータ仕様validDataSpecificationに挙げた 全てのプロパティ(入れ子のオブジェクト及び ECMAScript視点上オブジェクト特別な場合になっている 配列を含めて)を確認する。- デモ目的上、例の妥当なデータ仕様では型の確認に加えて追加の制約も指定してある。 たとえば
barプロパティは文字列であるだけでなく、 文字数が5以上でなければならない。 - 仕様に書いた各制約に対してRawObjectDataProcessorは個別の検査を行い、 実データと期待の不一致を見つけても直ぐに検査を止めたりはしない(入力自体がオブジェクトでない場合を除いて)。 代わりに不一致のメッセージを配列に蓄積し、其れは
processメソッド の戻り値経由で参照出来る。
- デモ目的上、例の妥当なデータ仕様では型の確認に加えて追加の制約も指定してある。 たとえば
- 検証で実データが定めた制約に一つも違反しなければ、RawObjectDataProcessorが 入力データに
asキーワードでジェネリック引数で指定された 型を(上の例ではSampleType)型付ける 過失を引き受ける。
RawObjectDataProcessorと型ガード(但し概念通り実装されたが、追加機能無し)の 共通点は次の通り。
- 生データが期待に合うかを確認する
- TypeScriptの根本的な制約に依り、特定の型への完全一致を 保証不可能
相違点だと、より多く有る。
- RawObjectDataProcessorはオブジェクト(特に添字配列)に 限定して動作する。 但しプロパティ・要素の型は JSONと互換であれば何でも良い。
- 戻り値は真偽値ではなく
多態のオブジェクト。 データが不正かどうかはisRawDataInvalidプロパティが表す。 此れがfalseの時、望む型にキャストされたオブジェクトをprocessedDataで参照出来、さもなくばvalidationErrorsMessagesプロパティに 期待データと実データの全不一について致メッセージが入る。 - 殆どのAPIは宣言的だが、必要に応じて追加で検証や処理を命令的に定義出来る。
- 各プロパティー・要素の型だけではなく、必要に応じて其の他の制限に満たしているか 検証可能。
- 必要な時検証に加えて元のオブジェクトに変更も加えられる。 例えば文字列として保存されている数値をJSONと未だ 非互換な
BigInt型に変換する場合や、 プロパティ名を変更する等の場合に非常に役に立っているので、此の機能自体が非推奨なってはいないが、 妥当性確認を壊すか、妥当として指示されたデータを不正にする事も出来るので、利用の際に注意を払わなければいけない。 重要な点としてRawObjectDataProcessorには2つの戦略が有り、 一つ目は元のオブジェクトを操作する(規定)、二つ目は元から新しいオブジェクトを構築する流れ。 元のオブジェクトを変更したい時、特に此の戦略の選択を考えなければいけない。
最後にRawObjectDataProcessorは実データと期待の不一致を説明する高品質なメッセージの 原形を備えている。 此れらは 文書化済み だが、ドキュメント無しでも内容が理解出来る様に書かれている。此のメッセージ原形のソースコードを御自身で確認可能で(ところで、日本語化も有る)、仮に御自身で此の様なメッセージを用意し、複数のプロジェクトに於いて流用を確保するには時間がどれぐらい必要が、見極めてもらいたい。