Here's one approach to implementing StringReplaceAll<T, M>
where T
is the string literal type we want to manipulate, and where M
is the "mapping" object of string key-value pairs to substitute:
type StringReplaceAll<T extends string, M extends { [k: string]: string },
A extends string = ""> =
T extends `${Extract<keyof M, string>}${infer R}` ? (
T extends `${infer K}${R}` ?
StringReplaceAll<R, M, `${A}${M[Extract<K, keyof M>]}`>
: never
) : T extends `${infer F}${infer R}` ? StringReplaceAll<R, M, `${A}${F}`> : A
Note that this is a tail recursive conditional type with A
as an accumulator for the final result; it starts off as the empty string type ""
and we concatenate things onto it as we recurse, and finally produce whatever A
is when we've run out of T
.
The idea of this is:
First we see if T
starts with a key of the M
mapper. If so, we split T
into that key K
and the rest of the string R
. In practice it takes two conditional types with infer
in them to make this happen to get first R
and then K
. Anyway, once we do that, we concatenate M[K]
onto the end of A
and recurse. That's the substitution part.
On the other hand, if T
doesn't start with a key of M
, then we just split it into the first character F
and the rest of the string R
, if possible. And we concatenate F
onto the end of A
and recurse more. That just copies the current character over without substituting.
Finally, if T
does not start with a key of M
and you can't even split it into at least one character, then it's empty, and you're done... and we just produce A
.
Let's make sure it works:
type Replaced =
StringReplaceAll<"greeting adjective World!", { greeting: "Hello", adjective: "Nice" }>
// type Replaced = "Hello Nice World!"
Looks good!
Note that I'm sure there are weird edge cases; if you have substitution keys where one is a prefix of another (i.e., key1.startsWith(key2)
is true), like { cap: "hat", cape: "shirt" }
then the algorithm will probably produce a union of all possible substitutions along with some other things which are not strictly possible because the slicing into K
and R
did not go smoothly:
type Hmm = StringReplaceAll<"I don my cap and cape", { cap: "hat", cape: "shirt" }>;
// type Hmm = "I don my hat and hat" | "I don my hat and shirt" |
// "I don my hat and hate" | "I don my hat and shirte"
Does it matter? I hope not, because fixing that would make things even more complicated, as we try to find (say) the longest key that matches. In any case I'm not going to worry too much about such situations, but keep in mind that for any complex type manipulation you should make sure to fully test against use cases you care about.
Playground link to code