In TypeScript, I'm working on a generic "transformer" function that will take an object and change its shape by renaming some of its properties, including properties in nested arrays and nested objects.
The actual renaming runtime code is easy, but I can't figure out the TypeScript typing. My type definition works for scalar properties and nested objects. But if a property is array-valued, the type definition loses type information for array elements. And if there are any optional properties on the object, type information is also lost.
Is what I'm trying to do possible? If yes, how can I support array properties and optional properties?
My current solution is a combination of this StackOverflow answer (thanks @jcalz!) to do the renaming and this GitHub example (thanks @ahejlsberg!) to handle the recursive part.
A self-contained code sample below (also here: https://codesandbox.io/s/kmyl013r3r) shows what's working and what's not.
// from https://stackoverflow.com/a/45375646/126352
type ValueOf<T> = T[keyof T];
type KeyValueTupleToObject<T extends [keyof any, any]> = {
[K in T[0]]: Extract<T, [K, any]>[1]
};
type MapKeys<T, M extends Record<string, string>> = KeyValueTupleToObject<
ValueOf<{
[K in keyof T]: [K extends keyof M ? M[K] : K, T[K]]
}>
>;
// thanks to https://github.com/Microsoft/TypeScript/issues/22985#issuecomment-377313669
export type Transform<T> = MapKeys<
{ [P in keyof T]: TransformedValue<T[P]> },
KeyMapper
>;
type TransformedValue<T> =
T extends Array<infer E> ? Array<Transform<E>> :
T extends object ? Transform<T> :
T;
type KeyMapper = {
foo: 'foofoo';
bar: 'barbar';
};
// Success! Names are transformed. Emits this type:
// type TransformOnlyScalars = {
// baz: KeyValueTupleToObject<
// ["foofoo", string] |
// ["barbar", number]
// >;
// foofoo: string;
// barbar: number;
// }
export type TransformOnlyScalars = Transform<OnlyScalars>;
interface OnlyScalars {
foo: string;
bar: number;
baz: {
foo: string;
bar: number;
}
}
export const fScalars = (a: TransformOnlyScalars) => {
const shouldBeString = a.foofoo; // type is string as expected.
const shouldAlsoBeString = a.baz.foofoo; // type is string as expected.
type test<T> = T extends string ? true : never;
const x: test<typeof shouldAlsoBeString>; // type of x is true
};
// Fails! Elements of array are not type string. Emits this type:
// type TransformArray = {
// foofoo: KeyValueTupleToObject<
// string |
// number |
// (() => string) |
// ((pos: number) => string) |
// ((index: number) => number) |
// ((...strings: string[]) => string) |
// ((searchString: string, position?: number | undefined) => number) |
// ... 11 more ... |
// {
// ...;
// }
// > [];
// barbar: number;
// }
export type TransformArray = Transform<TestArray>;
interface TestArray {
foo: string[];
bar: number;
}
export const fArray = (a: TransformArray) => {
const shouldBeString = a.foofoo[0];
const s = shouldBeString.length; // type of s is any; no intellisense for string methods
type test<T> = T extends string ? true : never;
const x: test<typeof shouldBeString>; // type of x is never
};
// Fails! Property names are lost once there's an optional property. Emits this type:
// type TestTransformedOptional = {
// [x: string]:
// string |
// number |
// KeyValueTupleToObject<["foofoo", string] | ["barbar", number]> |
// undefined;
// }
export type TransformOptional = Transform<TestOptional>;
interface TestOptional {
foo?: string;
bar: number;
baz: {
foo: string;
bar: number;
}
}
export const fOptional = (a: TransformOptional) => {
const shouldBeString = a.barbar; // type is string | number | KeyValueTupleToObject<["foofoo", string] | ["barbar", number]> | undefined
const shouldAlsoBeString = a.baz.foofoo; // error: Property 'foofoo' does not exist on type 'string | number | KeyValueTupleToObject<["foofoo", string] | ["barbar", number]>'.
};