TypeScript recursively convert object keys from PascalCase to camelCase format

Viewed 2506

I need to create a function to modify keys of object from PascalCase to camelCase format. Ie

const input = {
    FirstName: 'John',
    LastName: 'Smith'
};

const expectedOutput = {
    firstName: 'John',
    lastName: 'Smith'
};

// function looks as follows
function pascalToCamelCase<T>(input: T): CamelCaseKeys<T> {
    if (typeof input !== 'object' || input === null) {
        return input;
    }

    // do conversion....
    return output;
}

But I don't know how to modify keys accordingly via CamelCaseKeys

2 Answers

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:

enter image description here

Playground link

Here's a recursive solution that I've used to good effect:

export const lowerCaseKeys = (obj: any): any => {
    if (typeof obj !== 'object') {
        return obj;
    }
    if (Array.isArray(obj)) {
        return obj.map(lowerCaseKeys);
    }
    if (obj === null) {
        return null;
    }
    const entries = Object.entries(obj);
    const mappedEntries = entries.map(
        ([k, v]) => [
            `${k.substr(0, 1).toLowerCase()}${k.substr(1)}`,
            lowerCaseKeys(v)] as const
    );
    return Object.fromEntries(mappedEntries);
};

Playground link

Related