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]>'.
};
There are two problems.
The one with arrays is due to the fact that you need to apply the TransformedValue
logic to the E
parameter not the Transform
logic. That is you need to see if E
is an array type (and change the element type only) or object type (and transform the property names) and if it's neither you need to leave it alone (it's probably a primitive and we should not map it). Right now since you apply Transform
to E
the result is primitives will get mangled by the rename process.
Since type aliases can't be recursive, we can define an interface derived from array, which will apply TransformedValue
to its type parameter:
type TransformedValue<T> =
T extends Array<infer E> ? TransformedArray<E> :
T extends object ? Transform<T> :
T;
interface TransformedArray<T> extends Array<TransformedValue<T>>{}
The second problem has to do with the fact that if an interface has optional properties and the interface is put through a homomorphic mapped type, the optionality of the members will be preserved, and thus the result of T[keyof T]
will contain undefined
. And this will trip-up KeyValueTupleToObject
. The simplest solution is to remove the optionality explicitly
type MapKeys<T, M extends Record<string, string>> = KeyValueTupleToObject<
ValueOf<{
[K in keyof T]-?: [K extends keyof M ? M[K] : K, T[K]]
}>
>;
Putting it all together it should work: link
Edit A solution that keeps the types a bit more readable could use another of @jcalz answers that converts a union to an intersection (this one).
Also the solution below will keep the optionality of the types, readonly
is still lost:
type UnionToIntersection<U> =
(U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never
type MapKeysHelper<T, K extends keyof T, M extends Record<string, string>> = K extends keyof M ? (
Pick<T, K> extends Required<Pick<T, K>> ?
{ [P in M[K]]: T[K] } :
{ [P in M[K]]?: T[K] }
) : {
[P in K]: T[P]
}
type Id<T> = { [P in keyof T]: T[P] }
type MapKeys<T, M extends Record<string, string>> = Id<UnionToIntersection<MapKeysHelper<T, keyof T, M>>>;
export type Transform<T> = MapKeys<
{ [P in keyof T]: TransformedValue<Exclude<T[P], undefined>> },
KeyMapper
>;
type TransformedValue<T> =
T extends Array<infer E> ? TransformedArray<E> :
T extends object ? Transform<T> :
T;
interface TransformedArray<T> extends Array<TransformedValue<T>> { }
type KeyMapper = {
foo: 'foofoo';
bar: 'barbar';
};
interface OnlyScalars {
foo: string;
bar: number;
baz: {
foo: string;
bar: number;
}
}
export type TransformOnlyScalars = Transform<OnlyScalars>;
// If you hover you see:
// {
// foofoo: string;
// barbar: number;
// baz: Id<{
// foofoo: string;
// } & {
// barbar: number;
// }>;
// }
interface TestArray {
foo: string[];
bar: number;
}
export type TransformArray = Transform<TestArray>;
// If you hover you see:
// {
// foofoo: TransformedArray<string>;
// barbar: number;
// }
interface TestOptional {
foo?: string;
bar: number;
baz: {
foo: string;
bar: number;
}
}
export type TransformOptional = Transform<TestOptional>;
// If you hover you see:
// {
// foofoo?: string | undefined;
// barbar: number;
// baz: Id<{
// foofoo: string;
// } & {
// barbar: number;
// }>;
// }