Home > OS >  Replace Parts of Literal String with Values From Object
Replace Parts of Literal String with Values From Object

Time:05-11

I'm having trouble figuring out a particular bit of Typescript magic. I would like a StringReplaceAll generic which is used like this:

type Replaced = StringReplaceAll<"greeting adjective World!", { greeting: "Hello", adjective: "Nice" }>

and results in:

type Replaced = "Hello Nice World!"

So basically a string.replaceAll() for literals. I have it working with objects which have only one key (and subsequently only one placeholder in the string) but can't wrap my head around how to go on from there.

CodePudding user response:

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 Mmapper. 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

CodePudding user response:

Solution

It is possible. I used TuplifyUnion from here. See the disclaimer from the linked answer.

type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never
type LastOf<T> =
  UnionToIntersection<T extends any ? () => T : never> extends () => (infer R) ? R : never

type Push<T extends any[], V> = [...T, V];

type TuplifyUnion<T, L = LastOf<T>, N = [T] extends [never] ? true : false> =
  true extends N ? [] : Push<TuplifyUnion<Exclude<T, L>>, L>


type _StringReplaceAll<S extends string, TU extends any[], T extends Record<string, string>> = 
  TU[0] extends infer TU0 
    ? { 
      [KEY in keyof TU0]: KEY extends string 
        ? S extends `${infer L}${KEY}${infer R}` 
          ? TU extends [any, ...infer REST] 
            ?  REST extends [any] 
              ? _StringReplaceAll<`${L}${T[KEY]}${R}`, REST, T>
              : `${L}${T[KEY]}${R}`
            : `${L}${T[KEY]}${R}`
          : S
        : never
      }[keyof TU0]
    : never   

type StringReplaceAll<
  S extends string, 
  T extends Record<string, string>
> = _StringReplaceAll<S, TuplifyUnion<{
  [K in keyof T]: Record<K, T[K]>
}[keyof T]>, T>

Usage:

type Replaced = StringReplaceAll<"greeting adjective World!", { greeting: "Hello", adjective: "Nice" }>
// type Replaced = "Hello Nice World!"

Explanation

The algorithm is quite simple: We declare three generic types. S is the string where the replacement takes place. T is the object with the replacement strings and TU is a tuple made of T.

Why the tuple? By converting T to a Tuple, we can iterate over the tuple elements in a recursive way. We take the first element of T, do the string replacement and then call the type recursively with the rest of the elements.

This leads to an important characteristic. String replacement takes place in order from first property to last property.


Pros and Cons

The StringReplaceAll type is always guaranteed to produce a single string literal. Taking the example of @jcalz, it would look like this:

type Hmm = StringReplaceAll<"I don my cap and cape", { cap: "hat", cape: "shirt" }>
// type Hmm = "I don my hat and shirt"

But there is also a disadvantage: Each matching string only replaces the first occurence:

type Hmm2 = StringReplaceAll<"I don my cap and cap", { cap: "hat" }>
// type Hmm2 = "I don my hat and cap"

I might try to find a way to fix this too later.

Playground

  • Related