paint-brush
TypeScript'te Type-Safe JSON Serileştirmesinde Uzmanlaşmaile@nodge
23,893 okumalar
23,893 okumalar

TypeScript'te Type-Safe JSON Serileştirmesinde Uzmanlaşma

ile Maksim Zemskov11m2024/02/26
Read on Terminal Reader

Çok uzun; Okumak

Bu makale, JSON formatını kullanırken TypeScript'te veri serileştirmenin zorluklarını araştırıyor. Özellikle JSON.stringify ve JSON.parse fonksiyonlarının eksikliklerine odaklanılmaktadır. Bu sorunları çözmek için, bir T türünün güvenli bir şekilde JSON'a serileştirilip serileştirilemeyeceğini doğrulamak amacıyla JSONCompatible türünün kullanılmasını önerir. Ayrıca, JSON'dan güvenli bir şekilde seri durumdan çıkarma için Superstruct kütüphanesini önerir. Bu yöntem, tür güvenliğini artırır ve geliştirme sırasında hata tespitine olanak tanır.
featured image - TypeScript'te Type-Safe JSON Serileştirmesinde Uzmanlaşma
Maksim Zemskov HackerNoon profile picture
0-item
1-item
2-item


Hemen hemen her web uygulaması veri serileştirmesi gerektirir. Bu ihtiyaç şu durumlarda ortaya çıkar:


  • Ağ üzerinden veri aktarımı (örn. HTTP istekleri, WebSockets)
  • Verileri HTML'ye gömme (örneğin nemlendirme için)
  • Verileri kalıcı bir depolama alanında depolama (LocalStorage gibi)
  • İşlemler arasında veri paylaşımı (web çalışanları veya postMessage gibi)


Çoğu durumda, veri kaybı veya bozulması ciddi sonuçlara yol açabilir; bu da, geliştirme aşamasında mümkün olduğunca çok sayıda hatanın tespit edilmesine yardımcı olan kullanışlı ve güvenli bir serileştirme mekanizmasının sağlanmasını zorunlu hale getirir. Bu amaçlar doğrultusunda, veri aktarım formatı olarak JSON'u ve geliştirme sırasında statik kod kontrolü için TypeScript'i kullanmak uygundur.


TypeScript, JavaScript'in bir üst kümesi olarak hizmet eder; bu, JSON.stringify ve JSON.parse gibi işlevlerin kusursuz şekilde kullanılmasını sağlamalıdır, değil mi? Tüm avantajlarına rağmen TypeScript'in JSON'un ne olduğunu ve JSON'da serileştirme ve seri durumdan çıkarma için hangi veri türlerinin güvenli olduğunu doğal olarak anlamadığı ortaya çıktı.


Bunu bir örnekle açıklayalım.


TypeScript'te JSON Sorunu

Örneğin, bazı verileri LocalStorage'a kaydeden bir işlevi düşünün. LocalStorage nesneleri depolayamadığından burada JSON serileştirmesini kullanıyoruz:


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


Verileri LocalStorage'dan almak için de bir fonksiyona ihtiyacımız olacak.


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


Bu kodun nesi yanlış? İlk sorun, yorumu geri yüklerken, updatedAt alanı için Date yerine bir string türü alacak olmamızdır.


Bunun nedeni, JSON'un diziler ve nesnelerin yanı sıra yalnızca dört temel veri türüne ( null , string , number , boolean ) sahip olmasıdır. JSON'da bir Date nesnesinin yanı sıra JavaScript'te bulunan diğer nesnelerin (fonksiyonlar, Harita, Küme vb.) kaydedilmesi mümkün değildir.


JSON.stringify JSON biçiminde temsil edilemeyen bir değerle karşılaştığında tür dönüşümü gerçekleşir. Date nesnesi durumunda, bir dize elde ederiz çünkü Date nesnesi, Date nesnesi yerine bir dize döndüren toJson() yöntemini uygular.


 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


İkinci sorun, saveComment işlevinin, tarih alanının Date türünde olduğu PostComment türünü döndürmesidir. Ancak Date yerine string tipini alacağımızı zaten biliyoruz. TypeScript bu hatayı bulmamıza yardımcı olabilir ama neden olmasın?


TypeScript'in standart kitaplığında JSON.parse işlevinin (text: string) => any olarak yazıldığı ortaya çıktı. any kullanımından dolayı tür kontrolü esasen devre dışı bırakılır. Örneğimizde TypeScript, fonksiyonun Date nesnesi içeren bir PostComment döndüreceği yönündeki sözümüzü aldı.


Bu TypeScript davranışı uygunsuz ve güvensizdir. Bir dizeye Date nesnesi gibi davranmaya çalışırsak uygulamamız çökebilir. Örneğin, comment.updatedAt.toLocaleDateString() öğesini çağırırsak bozulabilir.


Aslında küçük örneğimizde, Date nesnesini sayısal bir zaman damgasıyla değiştirebiliriz; bu, JSON serileştirmesinde işe yarar. Ancak gerçek uygulamalarda veri nesneleri kapsamlı olabilir, türler birden fazla konumda tanımlanabilir ve geliştirme sırasında bu tür bir hatayı belirlemek zorlu bir görev olabilir.


TypeScript'in JSON anlayışını geliştirebilseydik ne olurdu?


Serileştirmeyle Başa Çıkma

Başlangıç olarak, TypeScript'in hangi veri türlerinin güvenli bir şekilde JSON'a serileştirilebileceğini anlamasını nasıl sağlayacağımızı bulalım. safeJsonStringify işlevini oluşturmak istediğimizi varsayalım; burada TypeScript, JSON'un serileştirilebilir olduğundan emin olmak için giriş veri biçimini kontrol edecektir.


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


Bu fonksiyonda en önemli kısım JSON formatında temsil edilebilecek tüm olası değerleri temsil eden JSONValue tipidir. Uygulama oldukça basittir:


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


Öncelikle tüm primitif JSON veri tiplerini açıklayan JSONPrimitive tipini tanımlıyoruz. Ayrıca seri hale getirildiğinde undefined değere sahip anahtarların atlanacağı gerçeğine dayanarak undefined türü de dahil ediyoruz. Seri durumdan çıkarma sırasında bu anahtarlar nesnede görünmez; çoğu durumda bu da aynı şeydir.


Daha sonra JSONValue tipini tanımlıyoruz. Bu tür, TypeScript'in kendilerine başvuran türler olan özyinelemeli türleri tanımlama yeteneğini kullanır. Burada JSONValue bir JSONPrimitive , bir JSONValue dizisi veya tüm değerlerin JSONValue türünde olduğu bir nesne olabilir. Sonuç olarak, JSONValue tipindeki bir değişken, sınırsız iç içe yerleştirmeye sahip diziler ve nesneler içerebilir. Bunların içindeki değerlerin JSON formatıyla uyumluluğu da kontrol edilecektir.


Artık aşağıdaki örnekleri kullanarak safeJsonStringify fonksiyonumuzu test edebiliriz:


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


Her şey düzgün çalışıyor gibi görünüyor. Fonksiyon tarihi sayı olarak iletmemize izin veriyor ancak Date nesnesini iletirsek hata veriyor.


Ancak, işleve aktarılan verilerin bir değişkende saklandığı ve tanımlanmış bir türe sahip olduğu daha gerçekçi bir örneği ele alalım.


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


Artık işler biraz çetrefilleşiyor. TypeScript, JSONValue türündeki bir işlev parametresine PostComment türünde bir değişken atamamıza izin vermiyor çünkü "'PostComment' türünde 'string' türü için dizin imzası eksik".


Peki indeks imzası nedir ve neden eksik? JSON formatında serileştirilebilecek nesneleri nasıl tanımladığımızı hatırlıyor musunuz?


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


Bu durumda, [key: string] dizin imzasıdır. Bu, "bu nesnenin, değerleri JSONValue türüne sahip olan dize biçiminde herhangi bir anahtara sahip olabileceği" anlamına gelir. PostComment türüne bir dizin imzası eklememiz gerektiği ortaya çıktı, değil mi?


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


Bunu yapmak, yorumun herhangi bir rastgele alan içerebileceği anlamına gelir; bu, bir uygulamada veri türlerini tanımlarken genellikle istenen sonuç değildir.


Dizin imzasıyla ilgili sorunun gerçek çözümü, tanımlanmış bir dizin imzası olmayan türler için bile alanlar üzerinde yinelemeli olarak yineleme yapılmasına olanak tanıyan Eşlenen Türler'den gelir. Jeneriklerle birleştirildiğinde bu özellik, herhangi bir T veri türünün JSON biçimiyle uyumlu başka bir JSONCompatible<T> türüne dönüştürülmesine olanak tanır.


 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ürü, belirli bir T türünün JSON'a güvenli bir şekilde serileştirilip serileştirilmeyeceğini denetleyen eşlenmiş bir türdür. Bunu, T tipindeki her özelliği yineleyerek ve aşağıdakileri yaparak yapar:


  1. T[P] extends JSONValue ? T[P] : ... koşullu tür, özelliğin türünün JSONValue türüyle uyumlu olup olmadığını doğrulayarak, güvenli bir şekilde JSON'a dönüştürülebilmesini sağlar. Bu durumda mülkün türü değişmeden kalır.
  2. T[P] extends NotAssignableToJson ? never : ... koşullu tür, özelliğin türünün JSON'a atanabilir olup olmadığını doğrular. Bu durumda, özelliğin türü, özelliğin son türden etkili bir şekilde filtrelenmesi için never biçimine dönüştürülür.
  3. Bu koşullardan hiçbiri karşılanmıyorsa, bir sonuca varılana kadar tür yinelemeli olarak kontrol edilir. Bu şekilde, türün bir dizin imzası olmasa bile çalışır.


unknown extends T ? never :... başlangıçta check unknown türün, esasen any türe eşdeğer olan boş bir nesne türü {} ye dönüştürülmesini önlemek için kullanılır.


Bir başka ilginç yön de NotAssignableToJson türüdür. İki TypeScript temel öğesinden (bigint ve sembol) ve olası herhangi bir işlevi açıklayan Function türünden oluşur. Function türü, JSON'a atanamayan değerlerin filtrelenmesinde çok önemlidir. Bunun nedeni, JavaScript'teki herhangi bir karmaşık nesnenin Object türünü temel alması ve prototip zincirinde en az bir işleve sahip olmasıdır (örneğin, toString() ). JSONCompatible türü tüm bu işlevleri yineler; dolayısıyla işlevlerin kontrol edilmesi, JSON'a serileştirilemeyen herhangi bir şeyi filtrelemek için yeterlidir.


Şimdi bu türü serileştirme fonksiyonunda kullanalım:


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


Artık işlev genel bir T parametresi kullanıyor ve JSONCompatible<T> bağımsız değişkenini kabul ediyor. Bu, JSON uyumlu bir tür olması gereken T türünde bir bağımsız değişken data aldığı anlamına gelir. Artık fonksiyonu indeks imzası olmayan veri türleriyle kullanabiliriz.


İşlev artık JSONCompatible<T> türünden uzanan genel bir T parametresi kullanıyor. Bu, JSON uyumlu bir tür olması gereken T türünde bir bağımsız değişken data kabul ettiği anlamına gelir. Sonuç olarak fonksiyonu indeks imzası olmayan veri tipleriyle kullanabiliriz.


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


Bu yaklaşım, ağ üzerinden veri aktarımı, HTML'ye veri gömme, localStorage'da veri depolama, çalışanlar arasında veri aktarımı vb. gibi JSON serileştirmenin gerekli olduğu durumlarda kullanılabilir. Ayrıca, toJsonValue yardımcısı, kesin olarak yazılmış bir nesne olmadan kullanılabilir. JSONValue türündeki bir değişkene bir dizin imzasının atanması gerekir.


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


Bu örnekte toJsonValue kullanmak, PostComment türündeki eksik dizin imzasıyla ilgili hatayı atlamamızı sağlar.


Seri durumdan çıkarmayla ilgilenme

Seri durumdan çıkarma söz konusu olduğunda, zorluk aynı anda hem daha basit hem de daha karmaşıktır çünkü alınan verinin formatına yönelik hem statik analiz kontrollerini hem de çalışma zamanı kontrollerini içerir.


TypeScript'in yazım sistemi açısından bakıldığında zorluk oldukça basittir. Aşağıdaki örneği ele alalım:


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


Bu örnekte any dönüş türünü unknown türle değiştiriyoruz. Neden unknown seçmelisiniz? Temel olarak, bir JSON dizesi yalnızca almayı beklediğimiz verileri değil, her şeyi içerebilir. Örneğin, veri formatı farklı uygulama sürümleri arasında değişebilir veya uygulamanın başka bir kısmı aynı LocalStorage anahtarına veri yazabilir. Bu nedenle unknown en güvenli ve en kesin seçimdir.


Ancak unknown türle çalışmak, yalnızca istenen veri türünü belirtmekten daha az kullanışlıdır. Tip dökümünün dışında, unknown tipi gerekli veri tipine dönüştürmenin birden fazla yolu vardır. Bu tür yöntemlerden biri, çalışma zamanında verileri doğrulamak ve veriler geçersizse ayrıntılı hatalar atmak için Superstruct kitaplığını kullanmaktır.


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


Burada, create işlevi bir tür koruması görevi görerek türü istenen Comment arayüzüne daraltır . Sonuç olarak, artık dönüş türünü manuel olarak belirtmemize gerek yok.


Güvenli bir seri durumdan çıkarma seçeneğinin uygulanması hikayenin yalnızca yarısıdır. Projedeki bir sonraki görevle uğraşırken onu kullanmayı unutmamak da aynı derecede önemlidir. Tüm anlaşmaların ve en iyi uygulamaların takip edilmesini sağlamak zor olabileceğinden, proje üzerinde büyük bir ekip çalışıyorsa bu özellikle zorlaşır.


TypeScript-eslint bu göreve yardımcı olabilir. Bu araç, güvenli olmayan any kullanım örneklerinin belirlenmesine yardımcı olur. Özellikle JSON.parse tüm kullanımları bulunabilir ve alınan verinin formatının kontrol edilmesi sağlanabilir. Kod tabanındaki any türden kurtulma hakkında daha fazla bilgi TypeScript'i Gerçekten "Kesinlikle Yazılmış" Yapma makalesinde okunabilir.


Çözüm

Güvenli JSON serileştirme ve seri durumdan çıkarma işlemlerine yardımcı olmak için tasarlanmış son yardımcı program işlevleri ve türleri burada verilmiştir. Bunları hazırlanan TS Oyun Alanında test edebilirsiniz.


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


Bunlar, JSON serileştirmesinin gerekli olduğu her durumda kullanılabilir.


Bu stratejiyi birkaç yıldır projelerimde kullanıyorum ve uygulama geliştirme sırasında olası hataları anında tespit ederek etkinliğini kanıtladı.


Bu makalenin size yeni bilgiler sunacağını umuyorum. Okuduğunuz için teşekkürler!

kullanışlı bağlantılar