For the purposes of dunking on the accepted answer, and to solve a more general case: The code below shows probably the best that can be done at the moment. From keysToCamelCase()
It splits word by the regular expression named at the top through types and the helper function, then changes those words to camel case. The helper deepMapKeys()
actually implements the copy function. You can also pass in an explicit maximum depth to camel case (or multiple depths, or number
to get the union of them).
// The root helper function.
function keysToCamelCase<T extends object, N extends number>(
target: T,
depth: N,
): CamelCaseProps<T, N> {
return deepMapKeys(
target,
(key) => (typeof key == "string" ? toCamelCase(key) : key),
depth,
) as any;
}
/**
* Matches words under the pattern: [0-9]+|[A-Z]?[a-z]+|[A-Z]+(?![a-z])
*/
type Words<S extends string> = S extends S
? string extends S
? string[]
: WordsAgg<S, []>
: never;
type WordsAgg<S extends string, L extends string[]> = S extends ""
? L
: S extends `${AsciiUpper}${AsciiLower}${string}`
? PascalWord<S, L>
: S extends `${AsciiUpper}${string}`
? UpperWord<S, L>
: S extends `${AsciiLower}${string}`
? CharsetWord<S, L, AsciiLower>
: S extends `${AsciiDigit}${string}`
? CharsetWord<S, L, AsciiDigit>
: S extends `${string}${infer Tail}`
? WordsAgg<Tail, L>
: never;
type PascalWord<
S extends string,
L extends string[],
> = S extends `${infer Head extends AsciiUpper}${infer Tail extends `${AsciiLower}${string}`}`
? CharsetWord<Tail, L, AsciiLower, Head>
: never;
type UpperWord<
S extends string,
L extends string[],
W extends string = "",
> = S extends `${AsciiUpper}${AsciiLower}${string}`
? WordsAgg<S, [...L, W]>
: S extends `${infer Next extends AsciiUpper}${infer Tail}`
? UpperWord<Tail, L, `${W}${Next}`>
: WordsAgg<S, [...L, W]>;
type CharsetWord<
S extends string,
L extends string[],
C extends string,
W extends string = "",
> = S extends `${infer Next extends C}${infer Tail}`
? CharsetWord<Tail, L, C, `${W}${Next}`>
: WordsAgg<S, [...L, W]>;
type AsciiDigit =
| "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
type AsciiUpper =
| "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "I" | "J" | "K" | "L"
| "M" | "N" | "O" | "P" | "Q" | "R" | "S" | "T" | "U" | "V" | "W" | "X"
| "Y" | "Z";
type AsciiLower = Lowercase<AsciiUpper>;
type PascalCase<S extends string> = S extends S
? string extends S
? string
: ApplyCapitalize<Words<S>, "">
: never;
type ApplyCapitalize<W extends string[], Acc extends string> = W extends []
? Acc
: W extends [infer T extends string, ...infer U extends string[]]
? ApplyCapitalize<U, `${Acc}${Capitalize<Lowercase<T>>}`>
: null;
type CamelCase<S extends string> = Uncapitalize<PascalCase<S>>;
type CamelCaseProps<T, N extends number = 1> = CamelCasePropsImpl<T, N, []>;
type CamelCasePropsImpl<
T,
N extends number,
Stack extends unknown[],
> = N extends Stack["length"]
? T
: T extends readonly (infer Elem)[]
? CamelCasePropsImpl<Elem, N, [unknown, ...Stack]>[]
: T extends object
?
| IfNumber<N, T>
| {
[K in keyof T as K extends string
? CamelCase<K>
: K]: CamelCasePropsImpl<T[K], N, [unknown, ...Stack]>;
}
: T;
type IfNumber<N, T> = number extends N ? T : never;
const WORDS_PATTERN = /[0-9]+|[A-Z]?[a-z]+|[A-Z]+(?![a-z])/g;
function words<S extends string>(input: S): Words<S> {
return (input.match(WORDS_PATTERN) ?? []) as any;
}
function uncapitalize<S extends string>(input: S): Uncapitalize<S> {
return (input.slice(0, 1).toLowerCase() + input.slice(1)) as any;
}
function capitalize<S extends string>(input: S): Capitalize<S> {
return (input.slice(0, 1).toUpperCase() + input.slice(1)) as any;
}
function toCamelCase<S extends string>(input: S): CamelCase<S> {
return uncapitalize(
words(input)
.map((word) => capitalize(word))
.join(""),
) as any;
}
function descriptorEntries(target: object) {
return Object.entries(Object.getOwnPropertyDescriptors(target));
}
function isObject(value: unknown): value is object {
return (typeof value == "object" && !!value) || typeof value == "function";
}
function copyPrototype(target: object) {
const proto = Reflect.getPrototypeOf(target);
const ctor = proto?.constructor;
if (Array.isArray(target)) {
return Reflect.construct(Array, [], ctor ?? Array);
} else {
return Reflect.construct(Object, [], ctor ?? Object);
}
}
function deepMapKeys(
target: object,
callback: (key: string | symbol) => string | symbol,
depth: number,
): object {
const seen = new WeakMap();
const root = copyPrototype(target);
const stack: any[] = [{ src: target, dest: root, depth }];
for (let top; (top = stack.pop()); ) {
const { src, dest, depth } = top;
for (const [key, descriptor] of descriptorEntries(src)) {
const newKey = callback(key);
const known = seen.get(descriptor.value);
if (known) {
descriptor.value = known;
} else if (0 < depth && isObject(descriptor.value)) {
const newSrc = descriptor.value;
const newDest = copyPrototype(newSrc);
descriptor.value = newDest;
stack.push({
src: newSrc,
dest: newDest,
depth: depth - 1,
});
}
Reflect.defineProperty(dest, newKey, descriptor);
}
}
return root;
}