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:


    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