2

I am using TypeScript 4.9.5 and I need to utilize type remapping. During the remapping process, I want to maintain the optional property mode. Specifically, I need to add a "child" prefix to each property of an interface and convert the original literal types of properties to uppercase. Here is my interface definition:

interface ChildrenProps {
    children: ReactNode;
    className?: string;
    style?: CSSProperties;
}

I expect to transform it into the following form:

interface ChildrenPropsWithPrefix {
    childChildren: ReactNode;
    childClassName?: string;
    childStyle?: CSSProperties;
}

To achieve this, I have written the following type utility:

/** Make all properties with a prefix and convert the first character of properties to uppercase */
type PrefixAll<T, P extends string> = {
    [K in keyof T & string as `${P}${Capitalize<K>}`]: T[K];
}

However, the result I obtained is as follows:

interface ChildrenPropsWithPrefix {
    childChildren: ReactNode;
    childClassName: string;
    childStyle: CSSProperties;
}

As you may notice, there is a difference between this result and my expected outcome. All the properties have become required, TS Playground link, whereas I want them to remain optional.

Although I can address this issue by making ParentProps extend Partial\<PrefixAll\<ChildrenProps, 'child'\>\>, it alters the original structure of the interface, which might confuse other developer. I would like to avoid such a situation.

Can you suggest a solution or alternative approach that allows me to achieve the desired result while maintaining the optional property mode? I would greatly appreciate any code examples or insights you can provide.

97z4moon
  • 55
  • 4
  • Does [this approach](https://tsplay.dev/Wv1ZQm) work for you? If yes, I'll write an answer explaining; If not, what am I missing? – wonderflame Jul 15 '23 at 10:42
  • @wonderflame you can get a homomorphic mapped type fairly easily, see [my answer](https://stackoverflow.com/a/76694437/2887218) – jcalz Jul 15 '23 at 15:23
  • @jcalz I've actually tried to do so, but for some reason, it didn't work – wonderflame Jul 15 '23 at 16:20

1 Answers1

2

You want PrefixAll<T, P> to be a homomorphic mapped type over the properties of T, meaning that it preserves optional and readonly property modifiers (see What does "homomorphic mapped type" mean? for more information). The standard way of doing that is to write {[K in keyof T]: ⋯} where the keys you are mapping over are keyof a generic type parameter. This works for remapped types via as also.

Your problem is that you wrote {[K in keyof T & string]: ⋯}, and the intersection breaks the connection to T, since (keyof T) & string is not keyof anything directly.

If you change keyof T & string to keyof T, the resulting mapped type will be homomorphic, as desired. But then you will not be allowed to use Captialize<K> because K might not be a string. Luckily, you can just write Captialize<K & string> instead. And when K & string evaluates to never, it has the effect of suppressing the property entirely, just as if you had written K in keyof T & string. So the solution is to move & string from the in clause to the as clause:

type PrefixAll<T, P extends string> = {
  [K in keyof T as `${P}${Capitalize<K & string>}`]: T[K];
}

Let's test it out:

interface ChildrenProps {
  children: ReactNode;
  className?: string;
  style?: CSSProperties;
}

type ChildrenPropsWithPrefix = PrefixAll<ChildrenProps, "child">;
/* type ChildrenPropsWithPrefix = {
    childChildren: ReactNode;
    childClassName?: string;
    childStyle?: CSSProperties;
} */

Looks good!

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360