Home > Net >  Passing C# Dictionary<string,string> into TypeScript Map<string,string>
Passing C# Dictionary<string,string> into TypeScript Map<string,string>

Time:12-31

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?

enter image description here

[ 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) and System.Text.Json will serialize Dictionary<String,String> to a JSON object with string-keys and string-value.

      • i.e. interface MyResponse { readonly [key: string]: string }
    • JSON can only contain object literals (i.e. some composition of JavaScript's object and Array values (expressed using {} and [] respectively), and other primitive literals (strictly limited to just string literals, number literals, and true/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.
    • But your TypeScript type MyResponse uses the Map 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 JSON object with string keys and string 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, not TitleCase.
    • Only use TypeScript interface types to describe JSON responses. Avoid using type 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 be readonly 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 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.

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"
}
  • Related