Search code examples
typescripttype-conversiontype-mapping

Replacing deeply nested values in a JSON with a value of a different type, while maintaining type-safety?


I wrote a function that takes as input a JSON object and a Map defining values to be replaced; and returns the same JSON object with all the occurrences of values replaced by the corresponding replacements -- which can be anything.

This changes the type of the object, but I cannot figure out how to reflect this change in TypeScript.

Here is the function:

function replaceJsonValues<Source, Replacement, Output>(
  obj: Source,
  translatedKeyData: Map<string, Replacement>
): Output {
  let stringifiedObject = JSON.stringify(obj);
  for (const [key, keyProp] of translatedKeyData.entries()) {
    stringifiedObject = stringifiedObject.replaceAll(`"${key}"`, JSON.stringify(keyProp));
  }
  return JSON.parse(stringifiedObject);
}

type SourceType = {
  foo: string; 
  baz: { 
    test: string;
  } 
}[]


type ReplacementType = {
  fancy: string;
}

const source: SourceType = [{ foo: "bar", baz: { test: "bar" } }];
const replacement: ReplacementType = { fancy: "replacement" };

const result = replaceJsonValues(source, new Map([["bar", replacement]]));
//    ^?


console.log(result) 

See in TS playground.

How do I modify this so that the Output type is correct?


Solution

  • A few issues to get out of the way:

    • I assume you care about the particular relationship between the key/value pairs in translatedKeyData. If so, you can't easily use a Map to do this. In TypeScript, Map is typed as Map<K, V>, a record-like type in which all the keys are considered to be the same type as each other, and all the values are considered to be the same type as each other... any association between a particular key and a particular value will be lost. There are ways to change the typing of Map so that it does keep track of such connections, as described in Typescript: How can I make entries in an ES6 Map based on an object key/value type, but it would be much easier to use a plain object to start with. Instead of new Map([["k1", v1], ["k2", v2], ...]) you would use {k1: v1, k2: v2, ...}. Instead of using the entries() method of Map, you could just use the Object.entries() method.

    • I would strongly recommend against performing string manipulation on JSON strings, as it is incredibly easy to accidentally produce invalid JSON this way. Consider

      replaceJsonValues({ a: "b", c: "d" }, new Map([[",", "oops"]])); 
      // 💥 RUNTIME ERROR! parsing "{"a":"b"oops"c":"d"}" 
      

      for example. Since you start and end with JavaScript values, you can perform a conceptually identical transformation by just walking through objects themselves, so that you are sure that you are only replacing string-valued keys or string-values values, and not weird pieces of things. The algorithm would look like:

      function replaceValues(obj: any, translatedKeyData: any) {
        if (typeof obj === "string") {
          return (obj in translatedKeyData) ? translatedKeyData[obj] : obj;
        }
        if (Array.isArray(obj)) {
          return obj.map(v => replaceValues(v, translatedKeyData));
        }
        if (obj && typeof obj === "object") {
          return Object.fromEntries(Object.entries(obj).map(([k, v]) => [
            ((k in translatedKeyData) &&
              typeof translatedKeyData[k] === "string"
            ) ? translatedKeyData[k] : k,
            replaceValues(v, translatedKeyData)]
          ));
        }
        return obj;
      }
      
    • Finally, this will only possibly work if the compiler knows the literal types of all the string values to replace on both the source obj and the mapping translatedKeyData. If any of these are widened to string, then the replacement will not be properly typed. That means you need to know the details of both the source and the mapping at compile time. If they are only known at runtime, then your options will be very limited. I will assume that you have compile-time known values, and that these values will be initialized with const assertions to give the maximum information to the compiler.


    Okay, now for the typings: I would do something like this:

    function replaceValues<S, M extends object>(
      obj: S,
      translatedKeyData: M
    ): ReplaceValues<S, M>;
    
    type ReplaceValues<S, M extends object> =
      S extends keyof M ? M[S] :
      S extends readonly any[] ? {
        [I in keyof S]: ReplaceValues<S[I], M>
      } :
      S extends object ? {
        [K in keyof S as (
          K extends keyof M ? M[K] extends string ? M[K] : K : K
        )]:
        ReplaceValues<S[K], M>
      } :
      S;
        
    

    So the source object is of generic type S, and the mapping object is of generic type M, and then the return type is ReplaceValues<S, M>, a recursive conditionalt type which goes through the different cases for S and performs replacements accordingly.

    First: S extends keyof M ? M[S] means that if the source S is a key in M, then you can just replace S with the corresponding property M[S] from M. That's the straight string value replacement: the type level version of the typeof obj === "string" code block in the implementation.

    Then: S extends readonly any[] ? { [I in keyof S]: ReplaceValues<S[I], M> } : means that if the source S is an arraylike type, then we map that arraylike type to another arraylike type where each value is replaced recursively. That's the type level version of the Array.isArray(obj) code block in the implementation.

    Then: S extends object ? { [K in keyof S as ( K extends keyof M ? M[K] extends string ? M[K] : K : K )]: ReplaceValues<S[K], M> } : means that if the source S is a non-array object, then we map the keys and values of the object so that any keys found in M are remapped, while recursively applying ReplaceValues to each value type. That's the type level version of the (obj && typeof obj === "object") code block in the implementation.

    Finally, if all those are false, then we return S. That is falling through the bottom, so that numbers stay numbers, and booleans stay booleans, etc. This is the type level version of the return obj at the bottom of the implementation.


    Okay, let's see if it works:

    const source = [{ foo: "bar", baz: { test: "bar" }, qux: 123 }] as const;
    const replacement: ReplacementType = { fancy: "replacement" };
    
    const result = replaceValues(source, { bar: replacement, qux: "foop" } as const);
    /* const result: readonly [{
        readonly foo: ReplacementType;
        readonly baz: {
            readonly test: ReplacementType;
        };
        readonly foop: 123;
    }] */
    

    Looks good! The compiler replaces the "bar" values with ReplacementType, and it replaces the "qux" key with "foop". That corresponds perfectly to the object that actually comes out at runtime:

    console.log(result);
    /* [{
      "foo": {
        "fancy": "replacement"
      },
      "baz": {
        "test": {
          "fancy": "replacement"
        }
      },
      "foop": 123
    }] */
    

    Playground link to code