16

I'm trying to type a fetcher component API that I'm working on. The idea is quite simple, you give it a fetcher (promise returning function) and a params array (representing the positional arguments) as props, and it will provide the results to a render prop.

type FunctionType<A extends any[] = any[], R = any> = (...args: A) => R

type Arguments<T> = T extends FunctionType<infer R, any> ? R : never

type PromiseReturnType<T> = T extends (...args: any[]) => Promise<infer R>
  ? R
  : never

type ChildrenProps<F> = {
  data: PromiseReturnType<F>
}

type Props<F> = {
  fetcher: F
  params: Arguments<F>
  children: (props: ChildrenProps<F>) => React.ReactNode
}

class Fetch<F> extends React.Component<Props<F>> {
  render() {
    return null
  }
}

const usage = (
  <div>
    <Fetch fetcher={getUser} params={[{ id: 5 }]}>
      {({ data: user }) => (
        <div>
          {user.name} {user.email}
        </div>
      )}
    </Fetch>

    <Fetch fetcher={getCurrentUser} params={[]}> // <-- Redundant since getCurrentUser doesn't have any params
      {({ data: user }) => (
        <div>
          {user.name} {user.email}
        </div>
      )}
    </Fetch>
  </div>
)

I was wondering if there was a way to say that if the fetcher function does not take arguments, the params prop should not be present or even optional?

I tried to modify the props as follows, only adding the params field if fetcher has > 0 arguments

type Props<F> = {
  fetcher: F
  children: (props: ChildrenProps<F>) => React.ReactNode
} & (Arguments<F> extends [] // no arguments
  ? {} // no params
  : { // same as before
      params: Arguments<F>
    })

Which actually works, however now in the render prop the data field is no longer typed correctly.

<Fetch fetcher={getUser} params={[{ id: 5 }]}>
  {({ data: user }) => ( // data: any, yet user: never...?
    <div>
      {user.name} {user.email} // name / email does not exist on type never
    </div>
  )}
</Fetch>

I'm not sure why this is the case.


UPDATE:

It seems that the modified Props is actually working from based on the following examples

type PropsWithGetUser = Props<typeof getUser>
type PropsWithGetCurrentUser = Props<typeof getCurrentUser>

const test1: PropsWithGetCurrentUser["children"] = input => input.data.email
const test2: PropsWithGetUser["children"] = input => input.data.email

that work without errors.

The resulting types of PropsWithGetUser and PropsWithGetCurrentUser are also inferred as the following which seems correct.

type PropsWithGetCurrentUser = {
  fetcher: () => Promise<User>
  children: (props: ChildrenProps<() => Promise<User>>) => React.ReactNode
}

type PropsWithGetUser = {
  fetcher: (
    {
      id,
    }: {
      id: number
    },
  ) => Promise<User>
  children: (
    props: ChildrenProps<
      (
        {
          id,
        }: {
          id: number
        },
      ) => Promise<User>
    >,
  ) => React.ReactNode
} & {
  params: [
    {
      id: number
    }
  ]
}

Could the issue have something to do with the way I am using this with React?

Lionel Tay
  • 1,274
  • 2
  • 16
  • 28

1 Answers1

4

This is possible in TypeScript 3.0.

To answer the question, I used ArgumentTypes<F> from
this answer to: How to get argument types from function in Typescript.

Here's how:
Demo on TypeScript playground

type ArgumentTypes<F extends Function> =
    F extends (...args: infer A) => any ? A : never;

type Props<F extends Function,
    Params = ArgumentTypes<F>> =
    Params extends { length: 0 } ?
    {
        fetcher: F;
    } : {
        fetcher: F;
        params: Params
    };

function runTest<F extends Function>(props: Props<F>) { }

function test0() { }
function test1(one: number) { }

runTest({
    fetcher: test0
    // , params: [] // Error
});
runTest({
    fetcher: test1
    // Comment it, 
    //  or replace it with invalid args
    //  and see error
    , params: [1]
});
Meligy
  • 35,654
  • 11
  • 85
  • 109
  • This works great. The use of the extra type parameter `Params = ArgumentTypes` seems to do the trick by resolving the arguments type in advance I'm guessing? Didn't occur to me to do that. Thanks. – Lionel Tay Oct 12 '18 at 05:26
  • 2
    Yeah. Resolving argument types is relatively easy, by itself, but the problem was that when you use the same type for another thing, in this case `params`, it uses this type of whatever that's set to in usage instead, and since the default value for the args is `any[]`, a lot of things fit to it. The real magic is in the conditional check `Params extends { length: 0 }` – Meligy Oct 12 '18 at 05:37
  • Actually I found that the types of `data` within the render prop is still not resolving correctly. I thought it was working because I was writing ` fetcher={getUser} params={[{ id: 5 }]}>` as suggested by Titian Cernicova-Dragomir. The part with params being optional when fetchers has no arguments was actually already working in my example. – Lionel Tay Oct 12 '18 at 06:08
  • This is very cool, but now the question is how to extend it to 3 or more conditional types? :) – fullStackChris Oct 08 '21 at 08:57