ほぼすべての データのシリアル化を必要とします。この必要性は次のような状況で発生します。 Web アプリケーションは ネットワーク経由のデータ転送 (HTTP リクエスト、WebSocket など) HTML へのデータの埋め込み (ハイドレーションなど) 永続ストレージ (LocalStorage など) へのデータの保存 プロセス間でのデータの共有 (Web ワーカーや postMessage など) 多くの場合、データの損失や破損は重大な結果につながる可能性があるため、開発段階でできるだけ多くのエラーを検出できる便利で安全なシリアル化メカニズムを提供することが不可欠です。これらの目的には、データ転送形式として 使用し、開発中の静的コード チェックには TypeScript を使用すると便利です。 JSON を TypeScript は JavaScript のスーパーセットとして機能し、これにより や などの関数をシームレスに使用できるようになります。多くの利点にもかかわらず、TypeScript は、JSON とは何か、またどのデータ型が JSON へのシリアル化および逆シリアル化に安全であるかを自然には理解していないことがわかりました。 JSON.stringify JSON.parse これを例で説明してみましょう。 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 つのプリミティブ データ型 ( 、 、 、 ) と配列およびオブジェクトしかないために発生します。 オブジェクトや、JavaScript にある他のオブジェクト (関数、Map、Set など) を JSON で保存することはできません。 null string number boolean Date で JSON 形式で表現できない値が検出されると、型キャストが発生します。 オブジェクトの場合、 オブジェクトは メソッドを実装しており、 オブジェクトの代わりに文字列を返すため、文字列を取得します。 JSON.stringify 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 番目の問題は、 関数が 型を返し、その日付フィールドの型が であることです。ただし、 の代わりに 型を受け取ることはすでにわかっています。 TypeScript はこのエラーを見つけるのに役立つ可能性がありますが、なぜ見つからないのでしょうか? saveComment PostComment Date Date string TypeScript の標準ライブラリでは、 関数は として型指定されていることがわかります。 を使用しているため、型チェックは基本的に無効になっています。この例では、TypeScript は、関数が オブジェクトを含む を返すという我々の言葉を単純に受け入れました。 JSON.parse (text: string) => any any Date PostComment この TypeScript の動作は不便であり、安全ではありません。文字列を オブジェクトのように扱おうとすると、アプリケーションがクラッシュする可能性があります。たとえば、 を呼び出すと壊れる可能性があります。 Date comment.updatedAt.toLocaleDateString() 実際、この小さな例では、 オブジェクトを数値のタイムスタンプに置き換えるだけで済み、これは JSON シリアル化に適しています。ただし、実際のアプリケーションでは、データ オブジェクトが膨大になる可能性があり、型は複数の場所で定義される可能性があるため、開発中にそのようなエラーを特定するのは困難な作業になる可能性があります。 Date 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 次に、 型について説明します。この型は、それ自体を参照する型である再帰型を記述する TypeScript の機能を使用します。ここで、 、 、 の配列、またはすべての値が 型であるオブジェクトのいずれかになります。その結果、このタイプの 変数には、無制限にネストされた配列とオブジェクトを含めることができます。これら内の値は、JSON 形式との互換性もチェックされます。 JSONValue JSONValue JSONPrimitive JSONValue JSONValue JSONValue ここで、次の例を使用して、 関数をテストできます。 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; }; これを行うと、コメントに任意のフィールドが含まれる可能性があることを意味しますが、これは通常、アプリケーションでデータ型を定義するときに望ましい結果ではありません。 インデックス署名に関する問題の本当の解決策は、インデックス署名が定義されていない型であっても、フィールドを再帰的に反復できるようにする Types から得られます。この機能をジェネリックと組み合わせると、任意のデータ型 、JSON 形式と互換性のある別の型 に変換できます。 Mapped T 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; 型は、指定された型 JSON に安全にシリアル化できるかどうかを検査するマップされた型です。これは、タイプ の各プロパティを反復処理し、次のことを実行することによって行われます。 JSONCompatible<T> T T 条件型は、プロパティの型が 型と互換性があるかどうかを検証し、安全に JSON に変換できることを確認します。この場合、プロパティのタイプは変更されません。 T[P] extends JSONValue ? T[P] : ... JSONValue 条件付きタイプは、プロパティのタイプが JSON に割り当て可能かどうかを検証します。この場合、プロパティの型は に変換され、最終的な型からプロパティが効果的に除外されます。 T[P] extends NotAssignableToJson ? never : ... never これらの条件のどちらも満たされない場合、結論が得られるまで型が再帰的にチェックされます。こうすることで、型にインデックス署名がない場合でも機能します。 最初の check は、 型が空のオブジェクト型 に変換されるのを防ぐために使用されます。これは、本質的に 型と同等です。 unknown extends T ? never :... unknown extends T ? never :... unknown {} any もう 1 つの興味深い点は、 型です。これは、2 つの TypeScript プリミティブ (bigint とシンボル) と、可能な関数を記述する タイプで構成されます。 タイプは、JSON に割り当てられない値を除外するために重要です。これは、JavaScript の複雑なオブジェクトはすべて Object 型に基づいており、そのプロトタイプ チェーンに少なくとも 1 つの関数 ( など) があるためです。 型はこれらの関数すべてを反復処理するため、関数をチェックするだけで JSON にシリアル化できないものを除外できます。 NotAssignableToJson Function Function toString() JSONCompatible ここで、この型をシリアル化関数で使用してみましょう。 function safeJsonStringify<T>(data: JSONCompatible<T>) { return JSON.stringify(data); } 現在、関数はジェネリック パラメーター を使用し、 引数を受け入れます。これは、型 の引数 を取ることを意味します。これは JSON 互換型である必要があります。これで、インデックス署名のないデータ型で関数を使用できるようになりました。 T JSONCompatible<T> T data この関数は、 型から拡張されたジェネリック パラメーター を使用するようになりました。これは、型 の引数 受け入れることを意味します。これは、JSON 互換型である必要があります。その結果、インデックス署名のないデータ型でも関数を利用できます。 JSONCompatible<T> T T data 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 この例では、 戻り値の型を 型に置き換えています。なぜ を選択するのでしょうか?基本的に、JSON 文字列には、受信すると予想されるデータだけでなく、あらゆるものを含めることができます。たとえば、アプリケーションのバージョンが異なるとデータ形式が変更されたり、アプリケーションの別の部分が同じ LocalStorage キーにデータを書き込んだりする可能性があります。したがって、 が最も安全で正確な選択です。 any unknown unknown unknown ただし、 型を扱うのは、単に目的のデータ型を指定するよりも利便性が低くなります。型キャストとは別に、 型を必要なデータ型に変換する方法は複数あります。そのような方法の 1 つは、 ライブラリを利用して実行時にデータを検証し、データが無効な場合は詳細なエラーをスローすることです。 unknown unknown 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 シリアル化が必要なあらゆる状況で使用できます。 私はこの戦略を自分のプロジェクトで数年間使用してきましたが、アプリケーション開発中に潜在的なエラーを迅速に検出することでその有効性を実証しました。 この記事があなたに新鮮な洞察を提供することを願っています。読んでくれてありがとう! 役立つリンク TypeScript を真に「厳密に型指定」する TypeScript の標準ライブラリ型の改善 スーパースタクト図書館 TS遊び場