typescripttypestype-level-computationhigher-kinded-typesrecursive-type

Deep Conditional Type of a Generic in TypeScript


So I have this helper function that allows me to replace types based on structural matching:

type Replace<T, C, A> = {
    [P in keyof T]: T[P] extends C ? A : T[P]
}

This allows me to do the following:

type NumberThing = { value: number }
type StringThing = Replace<NumberThing, number, string>

const a: StringThing = { value: "cenas" }

All is nice and dandy, until someone does this:

type ArrayOfNumberThing = { value: Array<number> }

Ok, so, I just add a new condition...

type Replace<T, C, A> = {
    [P in keyof T]: T[P] extends C ? A : (T[P] extends Array<C> ? Array<A> : T[P])
}

And it types:

type ArrayOfNumberThing = { value: Array<number>, simpleValue: number }
type ArrayOfStringThing = Replace<ArrayOfNumberThing, number, string>
const b: ArrayOfStringThing = { value: ["cenas"], simpleValue: "still works" }

But this guy is stubborn, and now throws me a:

type CrazyNumberThing = { value: Array<Array<Array<number>>> }

Well, I could always do this:

type RecursiveArrayReplace<T, C, A> = T extends C ? A : (T extends Array<infer E> ? RecursiveArrayReplace<E, C, A> : T)

... which would, obviously, search deeply into the Array until it finds what it wants to replace, right? Right? Wrong:

Type alias 'RecursiveArrayReplace' circularly references itself.

And before I could wipe my tears, someone just threw me a:

type TupleStringNumberThing = { value: [string, number] }

... which is making me curl up in a fetal position unless you guys help me :(


Solution

  • Given your particular examples, I'd write it like this:

    type DeepReplace<T, C, A> = T extends C ? A : T extends object ? {
        [P in keyof T]: DeepReplace<T[P], C, A> } : T;
    

    This should work for all your types including arrays/tuples if you're using TS3.1 or above:

    type NumberThing = { value: number }
    type StringThing = DeepReplace<NumberThing, number, string>
    // type StringThing = { value: string; }
    
    type ArrayOfNumberThing = { value: Array<number>, simpleValue: number }
    type ArrayOfStringThing = DeepReplace<ArrayOfNumberThing, number, string>;
    // type ArrayOfStringThing = { value: string[]; simpleValue: string; }
    
    type CrazyNumberThing = { value: Array<Array<Array<number>>> };
    type CrazyStringThing = DeepReplace<CrazyNumberThing, number, string>;
    // type CrazyStringThing = { value: string[][][]; }
    
    type TupleStringNumberThing = { value: [string, number] };
    type TupleStringStringThing = DeepReplace<TupleStringNumberThing, number, string>;
    // type TupleStringStringThing = { value: [string, string]; }
    

    Obviously you could find other edge cases... should functions that accept/return C should be with functions that accept/return A? These can likely be handled but it might not be worth the extra complexity if your use case doesn't need it.

    Okay, hope that helps; good luck!

    Playground link to code