typescripttypesgeneric-type-argument

Use generic type in function returns Type 'any[]' is not assignable to type 'T'


I want to convert the keys of a document to camelCase.

This was working fine with an unknown type, but at the end of the day, I need the type provided to perform operations later.

camelizeKeys(strings).map(() =>...); // <- Object is of type 'unknown'. ts(2571)

I decided to update the function with generic typings, getting the following errors (note that this works with unknown instead of T): Playground Link

const isObject = (value: unknown): value is Record<string, unknown> => {
    if (value == null) {
        return false;
    }
    return typeof value === 'object';
}

const camelCase = (s: string): string => s;

const camelizeKeys = <T extends unknown>(obj: T): T => {
    if (Array.isArray(obj)) {
        return obj.map((v) => camelizeKeys(v)); // <- Type 'any[]' is not assignable to type 'T'.
    } else if (isObject(obj)) {
        return Object.keys(obj).reduce( // <- Type '{}' is not assignable to type 'T'.
            (result, key) => ({
                ...result,
                [camelCase(key)]: camelizeKeys(obj[key])
            }),
            {}
        );
    }
    return obj;
};

Thanks in advance and happy coding!


Solution

  • In the general case, the compiler will not be able to verify that the implementation of camelizeKeys() will transform the input value of type T into an output value of type CamelizeKeys<T> where CamelizeKeys describes the type of such transformation. In your example, CamelizeKeys<T> would just be a no-op T, I guess, but below I will present a version that actually turns snake_case into CamelCase (upper camel case, because that's easier). Regardless:

    Inside the implementation of camelizeKeys(), the generic type parameter T is unspecified or unresolved. It could be any T at all, for all the compiler knows. Even if you check the type of obj, the compiler cannot use that information to narrow the type parameter T (see microsoft/TypeScript#13995 and microsoft/TypeScript#24085 for discussion on this). There are very few cases in which the compiler can verify that a particular value is assignable to anything related to an unspecified generic type parameter. Most of the time, and in general, the compiler will often complain that what you are doing isn't known to be safe. (See microsoft/TypeScript#33912 for a discussion of unresolved conditional generic types; I'm not sure there's a canonical issue for the general case of all unresolved generics... lots of related issues like microsoft/TypeScript#42493 but I haven't found anything I'd call definitive.)


    So, generic functions that do arbitrary manipulation of their input type will often need to be implemented with heavy use of type assertions, or the like, to work around this. It means that the implementer of the function has the responsibility to guarantee type safety, since the compiler cannot.


    As an example, let me rewrite camelCase() this way:

    type CamelCase<T extends string> = T extends `${infer F}_${infer R}`
        ? `${Capitalize<F>}${CamelCase<R>}` : Capitalize<T>;
    
    const camelCase = <T extends string>(s: T): CamelCase<T> =>
        s.split("_").map(x => x.charAt(0).toUpperCase() + x.slice(1)).join("") as any;
    

    Here I'm using template literal types to turn string snake case literal types like "foo_bar" into the analogous camel case literals like FooBar. So camelCase() acts on a value of type T extends string and returns a value of type CamelCase<T>. Note that I had to use a type assertion as any here because again, the compiler has no way to follow the logic of the implementation to determine if it matches the typings.

    Then, CamelizeKeys<T> will be a recursive mapped type with key remapping:

    type CamelizeKeys<T> = T extends readonly any[] ? 
    { [K in keyof T]: CamelizeKeys<T[K]> } : {
      [K in keyof T as K extends string ? CamelCase<K> : K]: CamelizeKeys<T[K]>
    }
    

    And the implmentation of camelizeKeys() is the same, but I give it a strongly typed call signature and a type assertion in every return:

    const camelizeKeys = <T,>(obj: T): CamelizeKeys<T> => {
        if (Array.isArray(obj)) {
            return obj.map((v) => camelizeKeys(v)) as any as CamelizeKeys<T>;
        } else if (isObject(obj)) {
            return Object.keys(obj).reduce(
                (result, key) => ({
                    ...result,
                    [camelCase(key)]: camelizeKeys(obj[key])
                }),
                {} as CamelizeKeys<T>
            );
        }
        return obj as any as CamelizeKeys<T>;
    };
    

    So that compiles with no error. Again, the caveat is that if we made a mistake in the implementation, the compiler will not notice. Anyway, let's test it:

    const foo = camelizeKeys({
        prop_one: "hello",
        prop_two: { prop_three: 123 },
        prop_four: [1, "two", { prop_five: "three" }] as const
    })
    
    console.log(foo.PropOne.toUpperCase()); // HELLO
    console.log(foo.PropTwo.PropThree.toFixed(2)); // 123.00
    console.log(foo.PropFour[0].toFixed(2)); // 1.00
    console.log(foo.PropFour[1].toUpperCase()); // TWO
    console.log(foo.PropFour[2].PropFive.toUpperCase()); // THREE
    

    This all works as expected. The compiler sees that foo has properties named PropOne, PropTwo, and PropFour, and all subproperties are similarly transformed, both at runtime and at compile time. There may well be edge cases, of course, but this is the general approach I would take for something like this.

    Playground link to code