I was trying to find a best way to handle key:value pairs in TypeScript when the c# backend returns a dictionary object but anything I tried so for is not working as expected.
this is my c# code:
var displayFileds = new Dictionary<string, string>();
displayFileds.Add("A", "A Value");
displayFileds.Add("B", "B Value");
this is TypeScript
type MyResponse = {
displayFields: Map<string,string>
}
console.log(response.displayFields)
//sample of Map created in Typescript directly
let map = new Map([
["A", "A value"],
["B", "B value"]
]);
console.log(map)
I expected to see the same values for the console.log but they are absolutely different, but why? If there is no way to deserialize the object directly into a Map then what is the best way to handle Dictionaries in TypeScript?
[ Edit ]
This is how the date is being fetched from the backend(I'm using Axios):
const { data } = await secureAxios.post<MyResponse>
(ApiEndpoints.GetResponse, request)
and the response type:
export type MyResponse= {
displayFields: Map<string,string>
}
As you can see from the above devtools output I'm not getting back a Map<string,string> as a type suggests but rather just an object, which is not what I want
CodePudding user response:
- Your TypeScript
type MyResponse
is wrong:By default, both
Newtonsoft.Json
(aka JSON.NET) andSystem.Text.Json
will serializeDictionary<String,String>
to a JSON object with string-keys and string-value.- i.e.
interface MyResponse { readonly [key: string]: string }
- i.e.
JSON can only contain object literals (i.e. some composition of JavaScript's
object
andArray
values (expressed using{}
and[]
respectively), and other primitive literals (strictly limited to juststring
literals,number
literals, andtrue
/false
/null
.- i.e. you cannot invoke a JavaScript class constructor function - nor use any object/type that requires a constructed object, so referencing the
Map
type in JSON is illegal.
- i.e. you cannot invoke a JavaScript class constructor function - nor use any object/type that requires a constructed object, so referencing the
But your TypeScript
type MyResponse
uses theMap
object, which, as mentioned above, is incorrect/wrong/illegal.
Possible solutions:
If the set of dictionary keys is "static" and forms part of your web-service's "contract" then you should use a well-defined C# DTO
class
and let your JSON serializer handle it for you.This is much better than having arbitrary and undocumented JSON object keys, and means you can use code-generation tools to automagically create web-service client-libraries for any platform out there (the web, TypeScript, Java, Rust, even COBOL).
MyResponse.cs
public class MyResponse { [JsonConstructor] public MyResponse( [JsonProperty("displayFields")] MyResponseFields displayFields ) { this.DisplayFields = displayFields ?? throw new ArgumentNullException(nameof(displayFields)); } [JsonProperty("displayFields")] public MyResponseFields DisplayFields { get; } } public class MyResponseFields { [JsonConstructor] public MyResponseFields( [JsonProperty("a")] String a, [JsonProperty("b")] String b ) { this.A = a; this.B = b; } [JsonProperty("a")] public String A { get; } [JsonProperty("a")] public String B { get; } }
MyResponse.ts
interface MyResponseFields { readonly a: string; readonly a: string; } interface MyResponse { readonly displayFields: MyResponseFields; }
If your
displayFields
is always dynamic and cannot be expressed using any kind of static interface (i.e. you really do want to send down arbitrary JSONobject
withstring
keys andstring
values), then use something like this:MyResponse.cs
public class MyResponse { [JsonConstructor] public MyResponse( [JsonProperty("displayFields")] IReadOnlyDictionary<String,String> displayFields ) { this.DisplayFields = displayFields ?? throw new ArgumentNullException(nameof(displayFields)); } [JsonProperty("displayFields")] public IReadOnlyDictionary<String,String> DisplayFields { get; } }
MyResponse.ts
interface ReadonlyStringDictionaryObject { readonly [key: string]: string; } interface MyResponse { readonly displayFields: ReadonlyStringDictionaryObject; }
And used like so in TypeScript (assuming you're using
fetch
):async function getMyResponse(): MyResponse { const resp = fetch( '/my/service/endpoint' ); if( resp.status === 200 && resp.headers.get('Content-Type')?.startsWith('application/json') ) { const json = await resp.json(); if( isMyResponse( json ) ) { return json; } } throw new Error( "something went wrong" ); } function isMyResponse( obj: unknown ): obj is MyResponse { return ( typeof obj === 'object' && obj !== null && 'displayFields' in obj && ( typeof ( obj as any ).displayFields === 'object' ); ); } function convertMyResponseToMap( r: MyResponse ): Map<string,string> { return new Map( Object.entries( r.displayFields ) ); } async function doStuff() { const myResponse = await getMyResponse(); const asMap = convertMyResponseToMap( myResponse ); console.log( asMap ); }
Also...
- In JSON, object-properties (aka keys) should always be
pascalCase
, notTitleCase
. - Only use TypeScript
interface
types to describe JSON responses. Avoid usingtype
because that makes it easier to inadvertently use non-JSON-safe techniques and types. - Also, TypeScript interfaces should describe immutable objects: all properties should be
readonly
, all collections should bereadonly T[]
and so on.- This is a good idea because it prevents things breaking in cases where you have two or more separate consumers of the same response object - where those consumers process the same response object-graph instance sequentially, and you don't want one altering the received response object such that it would break the other consumer.
- TypeScript's type-system is not entirely "sound", and data-mutations are an easy way to encounter unsound situations. Keeping things immutable makes everything else easier.
- This is a good idea because it prevents things breaking in cases where you have two or more separate consumers of the same response object - where those consumers process the same response object-graph instance sequentially, and you don't want one altering the received response object such that it would break the other consumer.
- This extends to C# too: note how the DTO
class
definitions are all immutable, using constructors to initialize themselves with argument validation, and all properties are read-only.- The
[JsonConstructor]
and[JsonProperty]
attributes on the class constructors is only needed if you intend to also deserialize the JSON from within C#. - If you're wondering why there's so much repetition (compared to a simpler mutable C# DTO class, blame the C# language designers for making custom constructors so tedious - however do consider using the new C#
record
types instead as that cuts down on a lot of tedium, but limits your ability to perform validation logic in the constructor.
- The
- In JSON, object-properties (aka keys) should always be
CodePudding user response:
Axios uses Convenience generic design pattern. You can provide the type of the response, but it is not enforced at runtime - it is equal to type assertion in terms of type safety.
Your C# backend serializes a Dictionary to following JSON string:
{
"A":"A Value",
"B":"B Value"
}
which is parsed by Axios to JS object:
{
A: "A Value",
B: "B Value"
}
This is precisely what you get logged from your response.
More generally, this means that:
- you shall specify type of the response if you trust backend to receive data in the right format
- a common trap is specifying a class as a response type - this won't work as
JSON.parse
will return a plain object, without establishing prototype chain.
You now have 2 options:
Use returned option as is, specifying the correct type
The correct type (matching the data actually returned from JSON.parse) is an indexed type:
type DisplayFields = {
[key: string]: string
}
This is idiomatic TS, and will give you the ability to access values by key.
Transform the result to Map
If you rely on having a Map
in the rest of your code:
- use the indexed type for Axios request
- transform it to a Map as soon as you receive the response.
Note: I am not sure about the shape of your data serialized by backend - if it is only the Map or object containing the Map under the displayFields property. Modify the return type on frontend side according to shape of received data.
CodePudding user response:
You can achieve the same output as response.displayFields
, by simply using Javascript object as:
const obj = {
A: "A value",
B: "B value"
}
and the same with typescript type definition can be written as:
const obj: Record<string, string> = {
A: "A value",
B: "B value"
}