Search code examples
typescripttypescript-generics

Typescript Overloads with Generics - Generic not found


I'm trying to create a utility function that will serve to extend fetch within our app, one of the things I want it to do is accept an optional Transform Function option to change the shape of the data that's returned;

type TransformFn<TResponse, TData> = (arg0: TResponse) => TData;

interface AppFetchInit extends RequestInit {
    errMsg?: string;
    transformFn?: undefined;
}

interface AppFetchInitWithTransformFn<TResponse, TData> extends Omit<AppFetchInit, 'transformFn'> {
    transformFn: TransformFn<TResponse, TData>;
}

interface AppFetchOverload<TResponse, TData = TResponse> {
    (apiURI: string, init: AppFetchInit): TResponse;
    (apiURI: string, init: AppFetchInitWithTransformFn<TResponse, TData>): TData;
}

export const appFetch: AppFetchOverload<TResponse, TData> = async (
    apiURI,
    { errMsg, transformFn, ...init } = {}
) => {
    const response = await fetch(apiURI, init);

    if (!response.ok)
        throw new Error(errMsg ?? `Fetching ${apiURI} failed: ${response.statusText}`);

    const responseJson = (await response.json()) as TResponse;

    return transformFn ? transformFn(responseJson) : responseJson;
};

I expect this to work, but I get errors on the implementation signature: Cannot find name 'TResponse' and of course Cannot find name 'TData'. This is in a .ts file, so it's not trying to parse <TResponse, TData> as jsx. I don't understand why I get these errors?

Also, I'm not convinced I'm right with the AppFetchInit and AppFetchInitWithTransformFn interfaces - AppFetch needs to have transformFn as an optional property in order to be able to destructure it in the implementation signature, but to my mind wouldn't this mean providing a transform function in a call would match the first signature and therefore type the return wrong?


Solution

  • It looks like AppFetchOverload's generic type parameters are scoped incorrectly. The current version says that the implementer of an AppFetchOverload<R, D> chooses R and D and the caller just has to deal with whatever the implementer chose. But what you want is apparently for the caller to choose R and D (or just R in the case of the first call signature) and then the implementer has to deal with anything the caller might have chosen. That means you want the call signatures to be generic, not the interface. Like so:

    interface AppFetchOverload {
        <TResponse>(
          apiURI: string, init: AppFetchInit
        ): TResponse;
        <TResponse, TData>(
          apiURI: string, init: AppFetchInitWithTransformFn<TResponse, TData>
        ): TData;
    }
    

    Now you can implement appFetch as an AppFetchOverload. Note how AppFetchOverload is not a generic type anymore, and so you don't have to give it type arguments (that was how you noticed this problem, that you tried to "declare" type arguments from nowhere, which isn't how it works). Maybe like this:

    export const appFetch: AppFetchOverload = async (
        apiURI: string,
        { errMsg, transformFn, ...init }: 
          AppFetchInit | AppFetchInitWithTransformFn<any, any> = {}
    ) => {
        const response = await fetch(apiURI, init);
    
        if (!response.ok)
            throw new Error(errMsg ?? `Fetching ${apiURI} failed: ${response.statusText}`);
    
        const responseJson = (await response.json());
    
        return transformFn ? transformFn(responseJson) : responseJson;
    };
    

    Note that I made that implementation quite loosely typed (using the any type) so that the compiler doesn't complain. You can't easily implement an overloaded function type with an arrow function, and no matter what, overloads aren't checked accurately; they're either too loose or too strict. See How to correctly overload functions in TypeScript? for more information.

    Anyway, now you can call the function either with the first overload (and you need to manually specify TResponse because there's nowhere from which to infer it):

    const c1 = appFetch<{ x: string }>(
        "", { errMsg: "" });
    // const c1: { x: string; }
    

    or the second overload (and here you can either manually specify both, or infer both, depending on your use case):

    const c2 = appFetch<{ x: string }, { y: string }>(
        "", { transformFn(a) { return { y: a.x } } }
    );
    // const c2: { y: string; }
    
    const c3 = appFetch("", {
        transformFn(a: { x: string }) { return { y: a.x } }
    });
    // const c3: { y: string; }
    

    Playground link to code