paint-brush
TypeScript でのタイプセーフな JSON シリアル化をマスターするby@nodge
12,445
12,445

TypeScript でのタイプセーフな JSON シリアル化をマスターする

Maksim Zemskov11m2024/02/26
Read on Terminal Reader

この記事では、JSON 形式を使用する場合の TypeScript でのデータのシリアル化の課題について説明します。特に、JSON.stringify 関数と JSON.parse 関数の欠点に焦点を当てています。これらの問題に対処するために、JSON互換型を使用して、型 T を JSON に安全にシリアル化できるかどうかを検証することを提案しています。さらに、JSON から安全に逆シリアル化するために Superstruct ライブラリを推奨します。この方法により型の安全性が向上し、開発中のエラー検出が可能になります。
featured image - TypeScript でのタイプセーフな JSON シリアル化をマスターする
Maksim Zemskov HackerNoon profile picture
0-item
1-item
2-item


ほぼすべてのWeb アプリケーションはデータのシリアル化を必要とします。この必要性は次のような状況で発生します。


  • ネットワーク経由のデータ転送 (HTTP リクエスト、WebSocket など)
  • HTML へのデータの埋め込み (ハイドレーションなど)
  • 永続ストレージ (LocalStorage など) へのデータの保存
  • プロセス間でのデータの共有 (Web ワーカーや postMessage など)


多くの場合、データの損失や破損は重大な結果につながる可能性があるため、開発段階でできるだけ多くのエラーを検出できる便利で安全なシリアル化メカニズムを提供することが不可欠です。これらの目的には、データ転送形式としてJSON を使用し、開発中の静的コード チェックには TypeScript を使用すると便利です。


TypeScript は JavaScript のスーパーセットとして機能し、これによりJSON.stringifyJSON.parseなどの関数をシームレスに使用できるようになります。多くの利点にもかかわらず、TypeScript は、JSON とは何か、またどのデータ型が JSON へのシリアル化および逆シリアル化に安全であるかを自然には理解していないことがわかりました。


これを例で説明してみましょう。


TypeScript における JSON の問題

たとえば、一部のデータを LocalStorage に保存する関数を考えてみましょう。 LocalStorage はオブジェクトを保存できないため、ここでは JSON シリアル化を使用します。


 interface PostComment { authorId: string; text: string; updatedAt: Date; } function saveComment(comment: PostComment) { const serializedComment = JSON.stringify(comment); localStorage.setItem('draft', serializedComment); }


LocalStorage からデータを取得する関数も必要になります。


 function restoreComment(): PostComment | undefined { const text = localStorage.getItem('draft'); return text ? JSON.parse(text) : undefined; }


このコードの何が問題なのでしょうか?最初の問題は、コメントを復元するときに、 updatedAtフィールドのDateではなくstring型を取得することです。


これは、JSON には 4 つのプリミティブ データ型 ( nullstringnumberboolean ) と配列およびオブジェクトしかないために発生します。 Dateオブジェクトや、JavaScript にある他のオブジェクト (関数、Map、Set など) を JSON で保存することはできません。


JSON.stringifyで JSON 形式で表現できない値が検出されると、型キャストが発生します。 Dateオブジェクトの場合、 DateオブジェクトはtoJson()メソッドを実装しており、 Dateオブジェクトの代わりに文字列を返すため、文字列を取得します。


 const date = new Date('August 19, 1975 23:15:30 UTC'); const jsonDate = date.toJSON(); console.log(jsonDate); // Expected output: "1975-08-19T23:15:30.000Z" const isEqual = date.toJSON() === JSON.stringify(date); console.log(isEqual); // Expected output: true


2 番目の問題は、 saveComment関数がPostComment型を返し、その日付フィールドの型がDateであることです。ただし、 Dateの代わりにstring型を受け取ることはすでにわかっています。 TypeScript はこのエラーを見つけるのに役立つ可能性がありますが、なぜ見つからないのでしょうか?


TypeScript の標準ライブラリでは、 JSON.parse関数は(text: string) => anyとして型指定されていることがわかります。 anyを使用しているため、型チェックは基本的に無効になっています。この例では、TypeScript は、関数がDateオブジェクトを含むPostCommentを返すという我々の言葉を単純に受け入れました。


この TypeScript の動作は不便であり、安全ではありません。文字列をDateオブジェクトのように扱おうとすると、アプリケーションがクラッシュする可能性があります。たとえば、 comment.updatedAt.toLocaleDateString()を呼び出すと壊れる可能性があります。


実際、この小さな例では、 Dateオブジェクトを数値のタイムスタンプに置き換えるだけで済み、これは JSON シリアル化に適しています。ただし、実際のアプリケーションでは、データ オブジェクトが膨大になる可能性があり、型は複数の場所で定義される可能性があるため、開発中にそのようなエラーを特定するのは困難な作業になる可能性があります。


TypeScript による JSON の理解を強化できたらどうなるでしょうか?


シリアル化への対処

まず、JSON に安全にシリアル化できるデータ型を TypeScript に認識させる方法を考えてみましょう。ここで、TypeScript が入力データ形式をチェックして、JSON シリアル化可能であることを確認する関数safeJsonStringifyを作成するとします。


 function safeJsonStringify(data: JSONValue) { return JSON.stringify(data); }


この関数で最も重要な部分は、JSON 形式で表現できるすべての値を表すJSONValueタイプです。実装は非常に簡単です。


 type JSONPrimitive = string | number | boolean | null | undefined; type JSONValue = JSONPrimitive | JSONValue[] | { [key: string]: JSONValue; };


まず、すべてのプリミティブ JSON データ型を記述するJSONPrimitive型を定義します。また、シリアル化すると、 undefined値を持つキーが省略されるという事実に基づいて、 undefined型も含めます。逆シリアル化中、これらのキーはオブジェクトに表示されませんが、ほとんどの場合は同じです。


次に、 JSONValue型について説明します。この型は、それ自体を参照する型である再帰型を記述する TypeScript の機能を使用します。ここで、 JSONValueJSONPrimitiveJSONValueの配列、またはすべての値がJSONValue型であるオブジェクトのいずれかになります。その結果、このタイプのJSONValue変数には、無制限にネストされた配列とオブジェクトを含めることができます。これら内の値は、JSON 形式との互換性もチェックされます。


ここで、次の例を使用して、 safeJsonStringify関数をテストできます。


 // No errors safeJsonStringify({ updatedAt: Date.now() }); // Yields an error: // Argument of type '{ updatedAt: Date; }' is not assignable to parameter of type 'JSONValue'. // Types of property 'updatedAt' are incompatible. // Type 'Date' is not assignable to type 'JSONValue'. safeJsonStringify({ updatedAt: new Date(); });


すべてが正しく機能しているようです。この関数を使用すると、日付を数値として渡すことができますが、 Dateオブジェクトを渡すとエラーが発生します。


しかし、関数に渡されるデータが変数に格納され、型が記述されている、より現実的な例を考えてみましょう。


 interface PostComment { authorId: string; text: string; updatedAt: number; }; const comment: PostComment = {...}; // Yields an error: // Argument of type 'PostComment' is not assignable to parameter of type 'JSONValue'. // Type 'PostComment' is not assignable to type '{ [key: string]: JSONValue; }'. // Index signature for type 'string' is missing in type 'PostComment'. safeJsonStringify(comment);


さて、事態は少し難しくなってきています。 TypeScript では、「型 'string' のインデックス署名が型 'PostComment' にありません」ため、 PostComment型の変数をJSONValue型の関数パラメータに割り当てることはできません。


では、インデックス署名とは何ですか?なぜそれが欠落しているのでしょうか? JSON 形式にシリアル化できるオブジェクトをどのように説明したかを覚えていますか?


 type JSONValue = { [key: string]: JSONValue; };


この場合、 [key: string]インデックス署名です。これは、「このオブジェクトは文字列形式の任意のキーを持つことができ、その値はJSONValue型を持つ」ことを意味します。つまり、 PostComment型にインデックス署名を追加する必要があることが分かりました。


 interface PostComment { authorId: string; text: string; updatedAt: number; // Don't do this: [key: string]: JSONValue; };


これを行うと、コメントに任意のフィールドが含まれる可能性があることを意味しますが、これは通常、アプリケーションでデータ型を定義するときに望ましい結果ではありません。


インデックス署名に関する問題の本当の解決策は、インデックス署名が定義されていない型であっても、フィールドを再帰的に反復できるようにするMapped Types から得られます。この機能をジェネリックと組み合わせると、任意のデータ型T 、JSON 形式と互換性のある別の型JSONCompatible<T>に変換できます。


 type JSONCompatible<T> = unknown extends T ? never : { [P in keyof T]: T[P] extends JSONValue ? T[P] : T[P] extends NotAssignableToJson ? never : JSONCompatible<T[P]>; }; type NotAssignableToJson = | bigint | symbol | Function;


JSONCompatible<T>型は、指定された型T JSON に安全にシリアル化できるかどうかを検査するマップされた型です。これは、タイプTの各プロパティを反復処理し、次のことを実行することによって行われます。


  1. T[P] extends JSONValue ? T[P] : ...条件型は、プロパティの型がJSONValue型と互換性があるかどうかを検証し、安全に JSON に変換できることを確認します。この場合、プロパティのタイプは変更されません。
  2. T[P] extends NotAssignableToJson ? never : ...条件付きタイプは、プロパティのタイプが JSON に割り当て可能かどうかを検証します。この場合、プロパティの型はneverに変換され、最終的な型からプロパティが効果的に除外されます。
  3. これらの条件のどちらも満たされない場合、結論が得られるまで型が再帰的にチェックされます。こうすることで、型にインデックス署名がない場合でも機能します。


unknown extends T ? never :...最初のunknown extends T ? never :... check は、 unknown型が空のオブジェクト型{}に変換されるのを防ぐために使用されます。これは、本質的にany型と同等です。


もう 1 つの興味深い点は、 NotAssignableToJson型です。これは、2 つの TypeScript プリミティブ (bigint とシンボル) と、可能な関数を記述するFunctionタイプで構成されます。 Functionタイプは、JSON に割り当てられない値を除外するために重要です。これは、JavaScript の複雑なオブジェクトはすべて Object 型に基づいており、そのプロトタイプ チェーンに少なくとも 1 つの関数 ( toString()など) があるためです。 JSONCompatible型はこれらの関数すべてを反復処理するため、関数をチェックするだけで JSON にシリアル化できないものを除外できます。


ここで、この型をシリアル化関数で使用してみましょう。


 function safeJsonStringify<T>(data: JSONCompatible<T>) { return JSON.stringify(data); }


現在、関数はジェネリック パラメーターTを使用し、 JSONCompatible<T>引数を受け入れます。これは、型Tの引数dataを取ることを意味します。これは JSON 互換型である必要があります。これで、インデックス署名のないデータ型で関数を使用できるようになりました。


この関数は、 JSONCompatible<T>型から拡張されたジェネリック パラメーターTを使用するようになりました。これは、型Tの引数data受け入れることを意味します。これは、JSON 互換型である必要があります。その結果、インデックス署名のないデータ型でも関数を利用できます。


 interface PostComment { authorId: string; text: string; updatedAt: number; } function saveComment(comment: PostComment) { const serializedComment = safeJsonStringify(comment); localStorage.setItem('draft', serializedComment); }


このアプローチは、ネットワーク経由でのデータ転送、HTML へのデータの埋め込み、localStorage へのデータの保存、ワーカー間でのデータ転送など、JSON シリアル化が必要な場合にいつでも使用できます。さらに、 toJsonValueヘルパーは、厳密に型指定されたオブジェクトが、インデックス署名はJSONValue型の変数に割り当てる必要があります。


 function toJsonValue<T>(value: JSONCompatible<T>): JSONValue { return value; } const comment: PostComment = {...}; const data: JSONValue = { comment: toJsonValue(comment) };


この例では、 toJsonValueを使用することで、 PostCommentタイプのインデックス署名の欠落に関連するエラーを回避できます。


逆シリアル化への対処

逆シリアル化に関しては、静的分析チェックと受信データ形式の実行時チェックの両方が必要となるため、課題は単純であると同時に複雑でもあります。


TypeScript の型システムの観点から見ると、この課題は非常に単純です。次の例を考えてみましょう。


 function safeJsonParse(text: string) { return JSON.parse(text) as unknown; } const data = JSON.parse(text); // ^? unknown


この例では、 any戻り値の型をunknown型に置き換えています。なぜunknownを選択するのでしょうか?基本的に、JSON 文字列には、受信すると予想されるデータだけでなく、あらゆるものを含めることができます。たとえば、アプリケーションのバージョンが異なるとデータ形式が変更されたり、アプリケーションの別の部分が同じ LocalStorage キーにデータを書き込んだりする可能性があります。したがって、 unknownが最も安全で正確な選択です。


ただし、 unknown型を扱うのは、単に目的のデータ型を指定するよりも利便性が低くなります。型キャストとは別に、 unknown型を必要なデータ型に変換する方法は複数あります。そのような方法の 1 つは、 Superstructライブラリを利用して実行時にデータを検証し、データが無効な場合は詳細なエラーをスローすることです。


 import { create, object, number, string } from 'superstruct'; const PostComment = object({ authorId: string(), text: string(), updatedAt: number(), }); // Note: we no longer need to manually specify the return type function restoreDraft() { const text = localStorage.getItem('draft'); return text ? create(JSON.parse(text), PostComment) : undefined; }


ここで、 create関数は型ガードとして機能し、型を目的のCommentインターフェイスに絞り込みます。その結果、戻り値の型を手動で指定する必要がなくなりました。


安全な逆シリアル化オプションの実装は、まだ半分にすぎません。プロジェクトの次のタスクに取り組むときに、それを忘れずに使用することも同様に重要です。大規模なチームがプロジェクトに取り組んでいる場合、すべての合意とベスト プラクティスに確実に従うことが難しいため、これは特に困難になります。


Typescript-eslint はこのタスクに役立ちます。このツールは、安全でany使用例をすべて特定するのに役立ちます。具体的には、 JSON.parseのすべての使用法を見つけることができ、受信したデータの形式がチェックされていることを確認できます。コードベース内のany型の削除の詳細については、「 TypeScript を真に "強く型指定" する」の記事を参照してください。


結論

ここでは、安全な JSON シリアル化と逆シリアル化を支援するために設計された最終的なユーティリティ関数と型を示します。これらは、用意されたTS Playgroundでテストできます。


 type JSONPrimitive = string | number | boolean | null | undefined; type JSONValue = JSONPrimitive | JSONValue[] | { [key: string]: JSONValue; }; type NotAssignableToJson = | bigint | symbol | Function; type JSONCompatible<T> = unknown extends T ? never : { [P in keyof T]: T[P] extends JSONValue ? T[P] : T[P] extends NotAssignableToJson ? never : JSONCompatible<T[P]>; }; function toJsonValue<T>(value: JSONCompatible<T>): JSONValue { return value; } function safeJsonStringify<T>(data: JSONCompatible<T>) { return JSON.stringify(data); } function safeJsonParse(text: string): unknown { return JSON.parse(text); }


これらは、JSON シリアル化が必要なあらゆる状況で使用できます。


私はこの戦略を自分のプロジェクトで数年間使用してきましたが、アプリケーション開発中に潜在的なエラーを迅速に検出することでその有効性を実証しました。


この記事があなたに新鮮な洞察を提供することを願っています。読んでくれてありがとう!

役立つリンク