Well, I learned something today! We can use the new typescript 4.1 template string literal features and utility methods to achieve this in a typesafe way.
Capitalize<T> will capitalise a string.
Uncapitalize<T> will uncapitalise a string (what you're after).
More info here on these.
From these two we can build a helper type UncapitalizeObjectKeys<T>:
type UncapitalizeKeys<T extends object> = Uncapitalize<keyof T & string>;
type UncapitalizeObjectKeys<T extends object> = {
[key in UncapitalizeKeys<T>]: Capitalize<key> extends keyof T ? T[Capitalize<key>] : never;
}
Notes:
Uncapitalize<keyof T & string> - we intersect with string to only get the keys of T which are strings, as we can't capitalise numbers or symbols
- We have to uncapitalise the keys in
[key in UncapitalizeKeys<T>] - and then re-capitalise them to actually pull the proper value out of T with T[Capitalize<key>]. The conditional part Capitalize<key> extends keyof T is just checking if the capitalised, uncapitalised key still is assignable to keyof T, as TS isn't able to maintain this relationship (...yet?).
We can then pull a couple of parts out of @spender's answer - replacing the runtime type checking as TS should be able to assert these (assuming these objects aren't coming from IO:
type UncapitalizeObjectKeys<T extends object> = {
[key in UncapitalizeKeys<T>]: Capitalize<key> extends keyof T ? T[Capitalize<key>] : never;
}
type UncapitalizeKeys<T extends object> = Uncapitalize<keyof T & string>;
export const lowerCaseKeys = <T extends object>(obj: T): UncapitalizeObjectKeys<T> => {
const entries = Object.entries(obj);
const mappedEntries = entries.map(
([k, v]) => [
`${k.substr(0, 1).toLowerCase()}${k.substr(1)}`,
lowerCaseKeys(v)]
);
return Object.fromEntries(mappedEntries) as UncapitalizeObjectKeys<T>;
};
We now get the output we're after:

Playground link