3

I have a custom hook in React that performs some side-effects for making fetch requests.

At it's core, the hook accepts a fetch function and returns a decorated version to be called by the component. When the decorated function is called, it sets the response to state and returns it as well (similar return structure to useState).

The passed in fetch function and the decorated function will have exactly the same parameters/types, but the return type will be different.

The issue I'm having is in adding types to both the returned state data and the returned decorated function. I can only seem to get one or the other working without forcing the user of the hook to provide what I believe is duplicate data.

// Dummy useState for dep-free example
function useState<State>(initial: State): [State, (newState: State) => void] {
    return [initial, (newState: State) => {}];
}

function useFetch<Data, FetchParams extends any[]>(
    fetchFunction: (...args: FetchParams) => Promise<Response>, 
    initial_value: Data | null = null
): [Data | null, (...args: FetchParams) => Promise<void>] {
    const [response, setResponse] = useState<Data | null>(initial_value);

    async function wrappedFetch(...args: FetchParams): Promise<void> {
        // Side Effects
        const res = await fetchFunction(...args);
        const result = await res.json();

        // Side Effects

        if (res.ok) {
            setResponse(result);
        }
        // Side Effects
    }

    return [
        response,
        wrappedFetch
    ];
}

In usage, if you provide the type for the returned state, you must also provide the types of the parameters, which I don't like. Using typescript's Parameters makes this easy, but it feels unnecessary. This hook will be used hundreds of times in the codebase, and I don't want to force devs to pass in what feels like duplicate data every time.

async function getStuff(value: string): Promise<Response> {
    return new Promise((res) => {
        res(new Response(value))
    })
}

interface SampleData {
    value: 'string';
}

const [test, getTest] = useFetch<SampleData, Parameters<typeof getStuff>>(getStuff)

getTest('test'); // Correctly typed, passing nothing or a non-string will error.

If you do not pass any type parameters to useFetch, it will infer the correct type of the function, but has no way of knowing the type of test.

Objective

I want to be able to use the hook with the correct type associated with both test and getTest, without needing to provide the arguments - like this:

const [test, getTest] = useFetch<SampleData>(getStuff)
// test = SampleData | null
// getTest = (value: string) => Promise<void>

What Else I've tried

  1. Using Parameters inside of the hook:
async function wrappedFetch(...args: Parameters<typeof fetchFunction>): Promise<void> {

But this seems to use the default type provided (any[]) instead of being based on the actual function passed in.

  1. Providing a default value to the FetchParams so that the developer can pass in the Data type with no FetchParams required:
function useFetch<Data, FetchParams extends any[] = any[]>(

But providing a default value prevents any type inference from happening making the FetchParams useless, and we're back to forcing the developer to pass in the arguments every time, just with no error/type safety if they choose not to.


Link to TypeScript Playground

Brian Thompson
  • 13,263
  • 4
  • 23
  • 43
  • TypeScript lacks "partial type parameter inference" as requested in [ms/TS#26242](https://github.com/microsoft/TypeScript/issues/26242). The usual workaround is currying, as shown [here](https://tsplay.dev/mxBBBw). I'm inclined to close this as a duplicate of existing questions like [this one](https://stackoverflow.com/q/69815021/2887218) unless I've missed the point of what you're trying to do. Let me know. – jcalz Jan 26 '22 at 17:19
  • @jcalz I will look at that answer and let you know. I did see a few of your other answers while researching this, but not that one! – Brian Thompson Jan 26 '22 at 17:22
  • @jcalz In my case, I would prefer my current situation over the use of currying. In my opinion, requiring the dev to curry a React hook is less intuitive than just providing the argument's types. The linked answer provides a valid workaround for the TypeScript limitation I'm running up against, so even though I won't be using it, if you feel that it's a duplicate I wouldn't fight you on it. – Brian Thompson Jan 26 '22 at 17:41
  • 1
    [This comment on the proposal](https://github.com/microsoft/TypeScript/issues/26242#issuecomment-895302405) really sums up my feeling on the workaround. It would force an unnatural API for all of my components, even though more than half aren't even using typescript yet to reap the benefits.. But that's my opinion, based on my use-case for it I guess. – Brian Thompson Jan 26 '22 at 17:47
  • 1
    Yeah, it's definitely a workaround and not a real solution; this is a pain point in TS, as evidenced by the frequency at which this sort of question seems to come up. I wish I had something better to say here other than, I guess, go to [ms/TS#26242](https://github.com/microsoft/TypeScript/issues/26242) and give it a to (negligibly) increase the likelihood that one day someone can say "just write `` and you're golden". ‍♂️ – jcalz Jan 26 '22 at 18:29
  • 1
    Other workarounds are "the dummy parameter", which in your case would look like `const [test, getTest] = useFetch(getStuff, null as SampleData | null)` as shown [here](https://tsplay.dev/N544Pw). That's still going to leave JS developers scratching their heads, probably. – jcalz Jan 26 '22 at 18:32
  • 1
    @jcalz Actually that's perfect.. While `initial_value` is optional, it's almost always used. So it would usually be something like `useFetch(getStuff, {} as SampleData)`, which is pretty clear to me, and much easier to do than `useFetch>(getStuff)`. Unlike currying, this does not change how a JS component would use the hook, and is much more similar to built-in and other custom hooks. Thank you – Brian Thompson Jan 26 '22 at 19:36

0 Answers0