Search code examples
typescriptnullablemapped-types

Typescript mapped type where only nullable properties are retained and converted to string type


I'm trying to create a new mapped type from an existing type. I'm looking to replace all nullable properties with a string type. Basic idea is to have the mapped type replace all nullable property types in sub-records with a string type. If the sub-record doesn't include any nullable types the sub-record itself is excluded from the mapped type. Also any property that is not an object is excluded.

interface OriginalType {
    foo: {
        bar: string | null;
        baz: number | null;
    };
    bar: {
        qux: string;
        quz: boolean | null;
        wobble: string | null;
    };
    baz: {
        grault: string;
        garply: number;
        flob: boolean;
    };
    version: number;
}

interface ExpectedType {
    foo: {
        bar: string;
        baz: string;
    };
    bar: {
        quz: string;
        wobble: string;
    };
}

I currently have written this mapped type so far:

type RetainNullablesAsString<T> = {
    [C in keyof T]: T[C] extends object ? {
        [K in keyof T[C]]: null extends T[C][K] ? string : never;
    }: never;
}

If testing it I get an error:

Property 'qux' is missing in type '{ quz: string; wobble: string; }' but required in type '{ qux: never; quz: string; wobble: string; }'.

I intend the qux property to be excluded from the type as it's not nullable but I don't know how to do that - I currently set it to never in the mapped type. The Pick utility type would't be of help here I think as I don't want to hardcode any properties to the mapped type. Also the baz record should be excluded as all of it's child properties are not nullable. Moreover I don't know if it's even possible to create a mapped type like this but would be glad if it was.

interface OriginalType {
    foo: {
        bar: string | null;
        baz: number | null;
    };
    bar: {
        qux: string;
        quz: boolean | null;
        wobble: string | null;
    };
    baz: {
        grault: string;
        garply: number;
        flob: boolean;
    };
    version: number;
}
  
const sourceRecord: OriginalType = {
    foo: {
        bar: 'bar',
        baz: 0,
    },
    bar: {
        qux: 'qux',
        quz: false,
        wobble: null,
    },
    baz: {
        grault: 'grault',
        garply: 1,
        flob: true,
    },
    version: 1,
}

//interface ExpectedType {
//    foo: {
//        bar: string;
//        baz: string;
//    };
//    bar: {
//        quz: string;
//        wobble: string;
//    };
//}

type RetainNullablesAsString<T> = {
    [C in keyof T]: T[C] extends object ? {
        [K in keyof T[C]]: null extends T[C][K] ? string : never;
    }: never;
}

const sourceRecordMetaData: RetainNullablesAsString<OriginalType> = {
    foo: {
        bar: 'bar-meta',
        baz: 'baz-meta',
    },
    bar: {
        quz: 'quz-meta',
        wobble: 'wobble-meta',
    },
}


Solution

  • interface OriginalType {
        foo: {
            bar: string | null;
            baz: number | null;
        };
        bar: {
            qux: string;
            quz: boolean | null;
            wobble: string | null;
        };
        baz: {
            grault: string;
            garply: number;
            flob: boolean;
        };
        version: number;
    }
    
    type IsNullableProperty<K extends keyof T, T> = null extends T[K] ? K : never;
    
    // Filtering out non nullable properties and convert rest to strings
    type ConvertSubRecord<T> = {
        [K in keyof T as IsNullableProperty<K, T>]: string;
    };
    
    type IsEmptyObject<T> = keyof T extends never ? never : T;
    
    // It transform property keys for non records and records that are converted into empty objects into never
    type IsNotEmptySubRecord<K extends keyof T, T> = T[K] extends object
        ? keyof ConvertSubRecord<T[K]> extends never
        ? never
        : K
        : never;
    
    type Convert<T> = {
        // Filter out with IsNotEmptySubRecord helper and convert subrecords with ConvertSubRecord
        [K in keyof T as IsNotEmptySubRecord<K, T>]: ConvertSubRecord<T[K]>;
    };
    
    interface ExpectedType {
        foo: {
            bar: string;
            baz: string;
        };
        bar: {
            quz: string;
            wobble: string;
        };
    }
    
    const res: Convert<OriginalType> = {
        bar: {
            quz: "",
            wobble: ""
        },
        foo: {
            bar: "",
            baz: "",
        },
    }