Search code examples
typescript

Replace multiple specific strings in typescript type


I'm looking for a way of replacing keys of a type when it contains the key of another. Each key will only contain a singular replacement (in the example 'abc' will only ever exist in a key once) which will need to be mapped over to its equivalent value in the replacement type.

Currently I'm doing:

type Replacements = {
    abc: 'cba';
    bcd: 'dcb';
};

type Test = {
    bcd: string;
    aabca: string;
};

type ReplaceKeys<R extends Record<string, string>, T extends Record<string, unknown>> = {
    [K in keyof T as K extends keyof R ? R[K] : K]: T[K];
  };
type Replaced = ReplaceKeys<Replacements, Test>;

Which gives me this type as expected:

type Replaced = {
    dcb: string;
    aabca: string;
}

However I'm wondering if there's a way of replacing keys that are contained. I've tried creating a type to do the job:

type ReplaceKeysInferred<
    R extends Record<string, string>,
    T extends Record<string, unknown>,
> = {
    [K in keyof T as K extends `${infer A}${infer RKey extends keyof R & string}${infer B}`
        ? `${A}${R[RKey]}${B}`
        : K]: T[K];
};

type ReplacedInferred = ReplaceKeysInferred<Replacements, Test>;

However the type returned doesn't contain any replacements:

type ReplacedInferred = {
    bcd: string;
    aabca: string;
}

Not sure where I'm going wrong.

Edit: I've also tried:

type ReplaceKeysInferred<
    R extends Record<string, string>,
    T extends Record<string, unknown>,
> = {
    [K in keyof T as K extends `${infer A}${infer RKey}${infer B}`
        ? RKey extends keyof R
            ? `${A}${R[RKey]}${B}`
            : K
        : K]: T[K];
};

Which is essentially an extra step on the first type. This works the same as the first type giving back:

type ReplacedInferred = {
    bcd: string;
    aabca: string;
}

Which still isn't replacing any contained strings.


Solution

  • The general approach would be

    type ReplaceKeysInferred<R extends { [k: string]: string }, T extends object> = {
        [K in keyof T as K extends string ? Substitute<R, K> : K]: T[K]
    }
    

    where Substitute<R, K> substitutes any matching entry from R inside the string K. One way to implement Substitute is:

    type Substitute<R extends { [k: string]: string }, T extends string> =
        OrDefault<{ [K in string & keyof R]:
            T extends `${infer F}${K}${infer L}` ? `${F}${R[K]}${L}` : never
        }[string & keyof R], T>
    
    type OrDefault<T, U> = [T] extends [never] ? U : T;
       
    

    We're mapping over the string keys of R and trying to match each such key K against some substring of T. That uses template literal inference of the form T extends `${infer F}${K}${infer L}` ? `${F}${R[K]}${L}` : never. So if K appears inside T, it is replaced with R[K]. Otherwise we get never.

    The reason that works and K extends `${infer A}${infer RKey}${infer B}` doesn't is because A, RKey, and B are all placeholder generic type parameters. And according to the description in microsoft/TypeScript#40336, the PR that implemented template literal types, "a placeholder immediately followed by another placeholder is matched by inferring a single character from the source." That means both A and RKey will be at most one character long. Whereas in `${infer F}${K}${infer L}`, K is not a placeholder.

    Anyway, then we index into the mapped type with string & keyof R, resulting in the union of substitutions for each key K in R. We expect that there will be at most one such substitution, so the union will either have one member, or it will be never.

    The OrDefault<T, U> utility type takes care of never. If the substitution exists, then we return it. Otherwise, we return the original key K. That way a failed substitution doesn't remove the key from the output.


    Okay, let's test it out:

    type Replacements = {
        abc: 'cba';
        bcd: 'dcb';
    };
    
    type Test = {
        bcd: string;
        aabca: string;
    };
    
    type ReplacedInferred = ReplaceKeysInferred<Replacements, Test>;
    /* type ReplacedInferred = {
        dcb: string;
        acbaa: string;
    } */
    

    Looks good. Note that the above implementation of Substitute depends strongly on the stated use case in the question. Future readers with different requirements, such as multiple or overlapping matches, would need to change the approach accordingly.

    Playground link to code