Gergő Horváth
Gergő Horváth

Reputation: 3705

Typescript How to write an object type from two objects, so the two merged together will match the merged object type?

I'm trying to write a function that accepts a function, and variables that will be passed to the function to fetch more objects for pagination. Meaning that the function's argument must accept skip, and limit, but can accept additional variables as well.

See the code to see what I mean:

type PaginationVariables = {
    skip: number;
    limit: number;
};

type Props<T, EV, V extends EV & PaginationVariables> = {
    extraVariables: EV;
    fetchMore: (variables: V) => Promise<T[]>;
}

function fn<T, EV, V extends EV & PaginationVariables>({ extraVariables, fetchMore }: Props<T, EV, V>) {
    const skip = 0;
    const limit = 20
    const handleClick = () => {
        fetchMore({ skip, limit, ...extraVariables }).then(() => {})
    }
}

But it throws:

Argument of type '{ skip: number; limit: number; } & EV' is not assignable to parameter of type 'V'.
  '{ skip: number; limit: number; } & EV' is assignable to the constraint of type 'V', but 'V' could be instantiated with a different subtype of constraint 'PaginationVariables'.

I've met with this error before, but I don't know what I'm doing wrong now.

See playground

Upvotes: 1

Views: 101

Answers (1)

jcalz
jcalz

Reputation: 330376

For a generic function, the caller gets to specify the type parameters. So in your version of fn(), nothing stops a caller from doing something like this:

fn({
  extraVariables: { a: "hello" },
  fetchMore(variables: { a: string, skip: number, limit: number, somethingElse: number[] }) {
    return Promise.resolve(variables.somethingElse.map(x => String(x)));
  }
});

/* fn<
     string, 
     { a: string; }, 
     { a: string; skip: number; limit: number; somethingElse: number[]; }
   >}(...) */

Here T is inferred as string, and EV is inferred as {a: string}. So far, so good. But V is inferred as { a: string; skip: number; limit: number; somethingElse: number[]; } because the fetchMore() method expects this type as its variables input. And since the type of V is a subtype of EV & PaginationVariables, the call is completely valid.

But that means, inside the implementation of fn(), a call like

fetchMore({ skip, limit, ...extraVariables })

is unsafe. In this case it'll end up calling the fetchMore method with only skip, limit, and a, and not somethingElse. And so at runtime variables.somethingElse will be undefined and not number[], and undefined has no map() method. Oops, that's a runtime error!

// 💥 TypeError: variables.somethingElse is undefined 💥

That's a problem. You do not want the fetchMore method to accept some arbitrary subtype of EV & PaginationVariables selected by the caller. You want it to accept only EV & PaginationVariables exactly. That is, you don't want anything to be generic in V at all. If you replace V with EV & PaginationVariables, everything starts to work the way you want it to:

type Props<T, EV> = {
    extraVariables: EV;
    fetchMore: (variables: EV & PaginationVariables) => Promise<T[]>;
}

function fn<T, EV>({ extraVariables, fetchMore }: Props<T, EV>) {
    const skip = 0;
    const limit = 20
    const handleClick = () => {
        fetchMore({ skip, limit, ...extraVariables }).then(() => { }) // okay
    }
}

And the formerly acceptable call to fn() is now unacceptable, because the fecthMore() method expects it input to have more properties than fn() is going to call it with:

fn({
    extraVariables: { a: "hello" },
    fetchMore(variables: { // error!
/*  ~~~~~~~~~ <-- Property 'somethingElse' is missing in 
      type '{ a: string; } & PaginationVariables */
        a: string, skip: number, limit: number, somethingElse: number[]
    }) { 
        return Promise.resolve(variables.somethingElse.map(x => String(x)));
    }
});

Playground link to code

Upvotes: 1

Related Questions