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?
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 aMapto do this. In TypeScript,Mapis typed asMap<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 ofMapso 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 ofnew Map([["k1", v1], ["k2", v2], ...])you would use{k1: v1, k2: v2, ...}. Instead of using theentries()method ofMap, you could just use theObject.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
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:
Finally, this will only possibly work if the compiler knows the literal types of all the string values to replace on both the source
objand the mappingtranslatedKeyData. If any of these are widened tostring, 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 withconstassertions to give the maximum information to the compiler.Okay, now for the typings: I would do something like this:
So the source object is of generic type
S, and the mapping object is of generic typeM, and then the return type isReplaceValues<S, M>, a recursive conditionalt type which goes through the different cases forSand performs replacements accordingly.First:
S extends keyof M ? M[S]means that if the sourceSis a key inM, then you can just replaceSwith the corresponding propertyM[S]fromM. That's the straight string value replacement: the type level version of thetypeof obj === "string"code block in the implementation.Then:
S extends readonly any[] ? { [I in keyof S]: ReplaceValues<S[I], M> } :means that if the sourceSis 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 theArray.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 sourceSis a non-array object, then we map the keys and values of the object so that any keys found inMare remapped, while recursively applyingReplaceValuesto 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 thereturn objat the bottom of the implementation.Okay, let's see if it works:
Looks good! The compiler replaces the
"bar"values withReplacementType, and it replaces the"qux"key with"foop". That corresponds perfectly to the object that actually comes out at runtime:Playground link to code