ほぼすべてのWeb アプリケーションはデータのシリアル化を必要とします。この必要性は次のような状況で発生します。
多くの場合、データの損失や破損は重大な結果につながる可能性があるため、開発段階でできるだけ多くのエラーを検出できる便利で安全なシリアル化メカニズムを提供することが不可欠です。これらの目的には、データ転送形式としてJSON を使用し、開発中の静的コード チェックには TypeScript を使用すると便利です。
TypeScript は JavaScript のスーパーセットとして機能し、これによりJSON.stringify
やJSON.parse
などの関数をシームレスに使用できるようになります。多くの利点にもかかわらず、TypeScript は、JSON とは何か、またどのデータ型が 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 つのプリミティブ データ型 ( null
、 string
、 number
、 boolean
) と配列およびオブジェクトしかないために発生します。 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 の機能を使用します。ここで、 JSONValue
、 JSONPrimitive
、 JSONValue
の配列、またはすべての値が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
の各プロパティを反復処理し、次のことを実行することによって行われます。
T[P] extends JSONValue ? T[P] : ...
条件型は、プロパティの型がJSONValue
型と互換性があるかどうかを検証し、安全に JSON に変換できることを確認します。この場合、プロパティのタイプは変更されません。T[P] extends NotAssignableToJson ? never : ...
条件付きタイプは、プロパティのタイプが JSON に割り当て可能かどうかを検証します。この場合、プロパティの型はnever
に変換され、最終的な型からプロパティが効果的に除外されます。
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 シリアル化が必要なあらゆる状況で使用できます。
私はこの戦略を自分のプロジェクトで数年間使用してきましたが、アプリケーション開発中に潜在的なエラーを迅速に検出することでその有効性を実証しました。
この記事があなたに新鮮な洞察を提供することを願っています。読んでくれてありがとう!