paint-brush
Nắm vững việc tuần tự hóa JSON an toàn kiểu trong TypeScriptby@nodge
12,526
12,526

Nắm vững việc tuần tự hóa JSON an toàn kiểu trong TypeScript

Maksim Zemskov11m2024/02/26
Read on Terminal Reader

Bài viết này khám phá những thách thức của việc tuần tự hóa dữ liệu trong TypeScript khi sử dụng định dạng JSON. Nó đặc biệt tập trung vào những thiếu sót của các hàm JSON.stringify và JSON.parse. Để giải quyết những vấn đề này, nó gợi ý sử dụng loại JSONCompatible để xác minh xem loại T có thể được tuần tự hóa thành JSON một cách an toàn hay không. Hơn nữa, nó khuyến nghị sử dụng thư viện Superstruct để giải tuần tự hóa an toàn khỏi JSON. Phương pháp này cải thiện tính an toàn của loại và cho phép phát hiện lỗi trong quá trình phát triển.
featured image - Nắm vững việc tuần tự hóa JSON an toàn kiểu trong TypeScript
Maksim Zemskov HackerNoon profile picture
0-item
1-item
2-item


Hầu hết mọi ứng dụng web đều yêu cầu tuần tự hóa dữ liệu. Nhu cầu này phát sinh trong các tình huống như:


  • Truyền dữ liệu qua mạng (ví dụ: yêu cầu HTTP, WebSockets)
  • Nhúng dữ liệu vào HTML (ví dụ: để hydrat hóa)
  • Lưu trữ dữ liệu trong bộ lưu trữ liên tục (như LocalStorage)
  • Chia sẻ dữ liệu giữa các quy trình (như nhân viên web hoặc postMessage)


Trong nhiều trường hợp, việc mất hoặc hỏng dữ liệu có thể dẫn đến hậu quả nghiêm trọng, do đó việc cung cấp cơ chế tuần tự hóa thuận tiện và an toàn giúp phát hiện nhiều lỗi nhất có thể trong giai đoạn phát triển là điều cần thiết. Vì những mục đích này, sẽ rất thuận tiện khi sử dụng JSON làm định dạng truyền dữ liệu và TypeScript để kiểm tra mã tĩnh trong quá trình phát triển.


TypeScript đóng vai trò như một siêu bộ JavaScript, cho phép sử dụng liền mạch các hàm như JSON.stringifyJSON.parse , phải không? Hóa ra, bất chấp tất cả những lợi ích của nó, TypeScript không tự nhiên hiểu JSON là gì và loại dữ liệu nào an toàn cho việc tuần tự hóa và giải tuần tự hóa thành JSON.


Hãy minh họa điều này bằng một ví dụ.


Sự cố với JSON trong TypeScript

Ví dụ, hãy xem xét một hàm lưu một số dữ liệu vào LocalStorage. Vì LocalStorage không thể lưu trữ các đối tượng nên chúng tôi sử dụng tuần tự hóa JSON tại đây:


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


Chúng tôi cũng sẽ cần một chức năng để lấy dữ liệu từ LocalStorage.


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


Có gì sai với mã này? Vấn đề đầu tiên là khi khôi phục lại bình luận, chúng ta sẽ nhận được kiểu string thay vì Date cho updatedAt .


Điều này xảy ra vì JSON chỉ có bốn kiểu dữ liệu nguyên thủy ( null , string , number , boolean ), cũng như các mảng và đối tượng. Không thể lưu đối tượng Date trong JSON, cũng như các đối tượng khác có trong JavaScript: hàm, Bản đồ, Tập hợp, v.v.


Khi JSON.stringify gặp một giá trị không thể biểu diễn ở định dạng JSON, việc truyền kiểu sẽ xảy ra. Trong trường hợp đối tượng Date , chúng ta nhận được một chuỗi vì đối tượng Date triển khai phương thức toJson() , phương thức này trả về một chuỗi thay vì đối tượng 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


Vấn đề thứ hai là hàm saveComment trả về kiểu PostComment , trong đó trường ngày thuộc kiểu Date . Nhưng chúng ta đã biết rằng thay vì Date , chúng ta sẽ nhận được một loại string . TypeScript có thể giúp chúng ta tìm ra lỗi này, nhưng tại sao lại không?


Hóa ra, trong thư viện chuẩn của TypeScript, hàm JSON.parse được gõ là (text: string) => any . Do việc sử dụng any , việc kiểm tra loại về cơ bản bị vô hiệu hóa. Trong ví dụ của chúng tôi, TypeScript chỉ đơn giản hiểu rằng hàm sẽ trả về một PostComment chứa đối tượng Date .


Hành vi TypeScript này bất tiện và không an toàn. Ứng dụng của chúng tôi có thể gặp sự cố nếu chúng tôi cố gắng xử lý một chuỗi như đối tượng Date . Ví dụ: nó có thể bị hỏng nếu chúng ta gọi comment.updatedAt.toLocaleDateString() .


Thật vậy, trong ví dụ nhỏ của chúng tôi, chúng tôi có thể chỉ cần thay thế đối tượng Date bằng dấu thời gian bằng số, hoạt động tốt cho việc tuần tự hóa JSON. Tuy nhiên, trong các ứng dụng thực, các đối tượng dữ liệu có thể mở rộng, các loại có thể được xác định ở nhiều vị trí và việc xác định lỗi như vậy trong quá trình phát triển có thể là một nhiệm vụ đầy thách thức.


Điều gì sẽ xảy ra nếu chúng ta có thể nâng cao hiểu biết của TypeScript về JSON?


Xử lý việc tuần tự hóa

Để bắt đầu, hãy tìm cách làm cho TypeScript hiểu loại dữ liệu nào có thể được tuần tự hóa thành JSON một cách an toàn. Giả sử chúng ta muốn tạo một hàm safeJsonStringify , trong đó TypeScript sẽ kiểm tra định dạng dữ liệu đầu vào để đảm bảo nó có thể tuần tự hóa JSON.


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


Trong hàm này, phần quan trọng nhất là loại JSONValue , đại diện cho tất cả các giá trị có thể được biểu thị ở định dạng JSON. Việc thực hiện khá đơn giản:


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


Đầu tiên, chúng tôi xác định loại JSONPrimitive , mô tả tất cả các loại dữ liệu JSON nguyên thủy. Chúng tôi cũng bao gồm loại undefined dựa trên thực tế là khi được tuần tự hóa, các khóa có giá trị undefined sẽ bị bỏ qua. Trong quá trình khử lưu huỳnh, các khóa này sẽ không xuất hiện trong đối tượng, điều này trong hầu hết các trường hợp đều giống nhau.


Tiếp theo, chúng tôi mô tả loại JSONValue . Loại này sử dụng khả năng của TypeScript để mô tả các loại đệ quy, là các loại tham chiếu đến chính chúng. Ở đây, JSONValue có thể là JSONPrimitive , một mảng JSONValue hoặc một đối tượng trong đó tất cả các giá trị đều thuộc loại JSONValue . Kết quả là, một biến thuộc loại JSONValue này có thể chứa các mảng và đối tượng với khả năng lồng nhau không giới hạn. Các giá trị trong đó cũng sẽ được kiểm tra tính tương thích với định dạng JSON.


Bây giờ chúng ta có thể kiểm tra hàm safeJsonStringify của mình bằng các ví dụ sau:


 // 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(); });


Mọi thứ dường như hoạt động đúng. Hàm này cho phép chúng ta truyền ngày dưới dạng số nhưng sẽ báo lỗi nếu truyền đối tượng Date .


Nhưng hãy xem xét một ví dụ thực tế hơn, trong đó dữ liệu được truyền đến hàm được lưu trữ trong một biến và có kiểu được mô tả.


 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);


Bây giờ, mọi thứ đang trở nên phức tạp hơn một chút. TypeScript sẽ không cho phép chúng ta gán biến loại PostComment cho tham số hàm loại JSONValue , vì "Chữ ký chỉ mục cho loại 'chuỗi' bị thiếu trong loại 'PostComment'".


Vậy chữ ký chỉ mục là gì và tại sao nó lại bị thiếu? Hãy nhớ cách chúng tôi mô tả các đối tượng có thể được tuần tự hóa sang định dạng JSON?


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


Trong trường hợp này, [key: string] là chữ ký chỉ mục. Nó có nghĩa là "đối tượng này có thể có bất kỳ khóa nào ở dạng chuỗi, các giá trị của chúng có loại JSONValue ". Vì vậy, hóa ra chúng ta cần thêm chữ ký chỉ mục vào loại PostComment , phải không?


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


Làm như vậy có nghĩa là nhận xét có thể chứa bất kỳ trường tùy ý nào, đây thường không phải là kết quả mong muốn khi xác định loại dữ liệu trong ứng dụng.


Giải pháp thực sự cho vấn đề với chữ ký chỉ mục đến từ Các loại đã ánh xạ , cho phép lặp lại đệ quy trên các trường, ngay cả đối với các loại không có chữ ký chỉ mục được xác định. Kết hợp với generics, tính năng này cho phép chuyển đổi bất kỳ loại dữ liệu T nào thành loại khác JSONCompatible<T> , tương thích với định dạng JSON.


 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;


Loại JSONCompatible<T> là loại được ánh xạ để kiểm tra xem loại T đã cho có thể được tuần tự hóa thành JSON một cách an toàn hay không. Nó thực hiện điều này bằng cách lặp lại từng thuộc tính trong loại T và thực hiện như sau:


  1. T[P] extends JSONValue ? T[P] : ... loại có điều kiện xác minh xem loại thuộc tính có tương thích với loại JSONValue hay không, đảm bảo nó có thể được chuyển đổi thành JSON một cách an toàn. Trong trường hợp này, loại thuộc tính vẫn không thay đổi.
  2. T[P] extends NotAssignableToJson ? never : ... loại có điều kiện xác minh xem loại thuộc tính có thể được gán cho JSON hay không. Trong trường hợp này, loại thuộc tính được chuyển đổi thành never , lọc thuộc tính ra khỏi loại cuối cùng một cách hiệu quả.
  3. Nếu cả hai điều kiện này đều không được đáp ứng, loại sẽ được kiểm tra đệ quy cho đến khi có thể đưa ra kết luận. Bằng cách này, nó hoạt động ngay cả khi loại không có chữ ký chỉ mục.


Cái unknown extends T ? never :... kiểm tra lúc đầu được sử dụng để ngăn loại unknown bị chuyển đổi thành loại đối tượng trống {} , về cơ bản tương đương với loại any .


Một khía cạnh thú vị khác là loại NotAssignableToJson . Nó bao gồm hai nguyên hàm TypeScript (bigint và ký hiệu) và loại Function , mô tả bất kỳ hàm nào có thể. Loại Function rất quan trọng trong việc lọc ra bất kỳ giá trị nào không thể gán cho JSON. Điều này là do bất kỳ đối tượng phức tạp nào trong JavaScript đều dựa trên loại Đối tượng và có ít nhất một hàm trong chuỗi nguyên mẫu của nó (ví dụ: toString() ). Loại JSONCompatible lặp lại tất cả các hàm đó, vì vậy, việc kiểm tra các hàm là đủ để lọc ra bất kỳ thứ gì không thể tuần tự hóa thành JSON.


Bây giờ, hãy sử dụng loại này trong hàm tuần tự hóa:


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


Bây giờ, hàm sử dụng tham số chung T và chấp nhận đối số JSONCompatible<T> . Điều này có nghĩa là nó lấy data đối số thuộc loại T , loại này phải là loại tương thích với JSON. Bây giờ chúng ta có thể sử dụng hàm với các kiểu dữ liệu mà không cần chữ ký chỉ mục.


Hàm hiện sử dụng tham số chung T mở rộng từ loại JSONCompatible<T> . Điều này có nghĩa là nó chấp nhận data đối số thuộc loại T , phải là loại tương thích với JSON. Kết quả là chúng ta có thể sử dụng hàm với các kiểu dữ liệu thiếu chữ ký chỉ mục.


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


Cách tiếp cận này có thể được sử dụng bất cứ khi nào cần tuần tự hóa JSON, chẳng hạn như truyền dữ liệu qua mạng, nhúng dữ liệu vào HTML, lưu trữ dữ liệu trong localStorage, truyền dữ liệu giữa các nhân viên, v.v. Ngoài ra, trình trợ giúp toJsonValue có thể được sử dụng khi một đối tượng được nhập đúng không có chữ ký chỉ mục cần được gán cho một biến kiểu JSONValue .


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


Trong ví dụ này, việc sử dụng toJsonValue cho phép chúng tôi bỏ qua lỗi liên quan đến chữ ký chỉ mục bị thiếu trong loại PostComment .


Xử lý quá trình khử lưu huỳnh

Khi nói đến quá trình khử lưu huỳnh, thách thức vừa đơn giản vừa phức tạp hơn vì nó bao gồm cả kiểm tra phân tích tĩnh và kiểm tra thời gian chạy đối với định dạng của dữ liệu nhận được.


Từ góc nhìn của hệ thống kiểu chữ của TypeScript, thử thách này khá đơn giản. Hãy xem xét ví dụ sau:


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


Trong trường hợp này, chúng tôi thay thế kiểu trả về any bằng kiểu unknown . Tại sao chọn unknown ? Về cơ bản, một chuỗi JSON có thể chứa bất kỳ thứ gì, không chỉ dữ liệu mà chúng ta mong đợi nhận được. Ví dụ: định dạng dữ liệu có thể thay đổi giữa các phiên bản ứng dụng khác nhau hoặc một phần khác của ứng dụng có thể ghi dữ liệu vào cùng một khóa LocalStorage. Vì vậy, unknown là sự lựa chọn an toàn và chính xác nhất.


Tuy nhiên, làm việc với kiểu dữ liệu unknown sẽ kém thuận tiện hơn so với việc chỉ xác định kiểu dữ liệu mong muốn. Ngoài việc ép kiểu, còn có nhiều cách để chuyển đổi kiểu unknown thành kiểu dữ liệu được yêu cầu. Một phương pháp như vậy là sử dụng thư viện Superstruct để xác thực dữ liệu trong thời gian chạy và đưa ra các lỗi chi tiết nếu dữ liệu không hợp lệ.


 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; }


Ở đây, hàm create hoạt động như một trình bảo vệ kiểu, thu hẹp loại vào giao diện Comment mong muốn. Do đó, chúng ta không cần phải chỉ định kiểu trả về theo cách thủ công nữa.


Việc triển khai tùy chọn khử lưu huỳnh an toàn mới chỉ là một nửa câu chuyện. Điều quan trọng không kém là đừng quên sử dụng nó khi giải quyết nhiệm vụ tiếp theo trong dự án. Điều này trở nên đặc biệt khó khăn nếu một nhóm lớn đang thực hiện dự án, vì việc đảm bảo tuân thủ tất cả các thỏa thuận và thực tiễn tốt nhất có thể khó khăn.


Typescript-eslint có thể hỗ trợ công việc này. Công cụ này giúp xác định tất cả các trường hợp sử dụng any an toàn. Cụ thể, tất cả các cách sử dụng JSON.parse đều có thể được tìm thấy và có thể đảm bảo rằng định dạng của dữ liệu nhận được đã được kiểm tra. Bạn có thể đọc thêm về cách loại bỏ any loại nào trong cơ sở mã trong bài viết Tạo TypeScript thực sự "được gõ mạnh" .


Phần kết luận

Dưới đây là các hàm và kiểu tiện ích cuối cùng được thiết kế để hỗ trợ quá trình tuần tự hóa và giải tuần tự hóa JSON an toàn. Bạn có thể kiểm tra những điều này trong TS Playground đã được chuẩn bị sẵn.


 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); }


Chúng có thể được sử dụng trong mọi tình huống cần tuần tự hóa JSON.


Tôi đã sử dụng chiến lược này trong các dự án của mình được vài năm và nó đã chứng minh tính hiệu quả của nó bằng cách phát hiện kịp thời các lỗi tiềm ẩn trong quá trình phát triển ứng dụng.


Tôi hy vọng bài viết này đã cung cấp cho bạn một số hiểu biết mới. Cảm ơn bạn đã đọc!

Liên kết hữu ích