0

I have the following code:

const urls = {
    inviteNewUser: ({teamId, intent = 'invite'}: {teamId: string; intent?: Intent}) => `/magic-link?intent=${intent}&teamId=${teamId}`,
    resetPassword: ({intent = 'reset-password'}: {intent?: Intent}) => `/magic-link?intent=${intent}`,
} as const

type URLS = typeof urls
type OptionArgs<T extends keyof URLS> = Parameters<URLS[T]>[0]

export function getUrl<T extends keyof URLS>(
    key: T,
    options: {
        args: OptionArgs<T>
    } = {
    args: {}
  },
) {
    const urlCreator: URLS[T] = urls[key]

    const args = options.args // <- this should be strictly typed according to the key passed in
    const url = urlCreator(args) // <- but here I get the error `Property 'teamId' is missing in type '{....`

    return url
}

Which throws the following type error:

Argument of type '{ teamId: string; intent?: Intent | undefined; } | { intent?: Intent | undefined; }' is not assignable to parameter of type '{ teamId: string; intent?: Intent | undefined; }'.
  Property 'teamId' is missing in type '{ intent?: Intent | undefined; }' but required in type '{ teamId: string; intent?: Intent | undefined; }'

I don't understand why this error happens. To my understanding, it should be clear that URLS[T] and Parameters<URLS[T]>[0] should always match. What is the reason that doesn't work, and how can I fix it?

jsco
  • 25
  • 4
  • 1
    This is an issue with correlated unions as described at [ms/TS#30581](https://github.com/microsoft/TypeScript/issues/30581) and the recommended refactoring is described at [ms/TS#47109](https://github.com/microsoft/TypeScript/pull/47109). If I follow that I get [this version](https://tsplay.dev/wX171N). Does that fully address the question? If so I'll write up an answer explaining; if not, what am I missing? – jcalz Aug 14 '23 at 15:07
  • @jcalz in some ways, but now the function call isn't strictly typed anymore: [playground](https://tsplay.dev/Nry5Dw), so I can just call `getUrl('inviteNewUser')` without any parameters even though they should be required here. Will work my way through that github thread and see if I can find another approach – jsco Aug 15 '23 at 03:53
  • You say "now" the function call isn't strictly typed "anymore", but I didn't change how the function could be called: [see playground link](https://tsplay.dev/w2va8w). I don't know what you want to do there or why it's relevant to the question. Can I proceed with my answer or do you want to [edit] the question so that the issue you're talking about isn't present? – jcalz Aug 15 '23 at 12:36

1 Answers1

1

When you create the urlCreator in your function body with this -

const urlCreator: URLS[T] = urls[key];

You're creating a union of both the function types, and for any kind of union, typescript does type narrowing, for instance -

type Test = string | "something";
//   ^? type Test = string;
type Test1 = number | 199;
//   ^? type Test1 = number;

Similary, when you create the urlCreator, typescript does narrowing on it, to determine if there's one type that would encompass both, and when it does, what it finds is -

const url = urlCreator(args) // <- but here I get the error `Property 'teamId' is missing in type '{....`
    //          ^? const urlCreator: ({ teamId, intent }: { teamId: string; intent?: any;
}) => string

Why does that happen? Whenever the type narrowing happens, and also, generally, the parameters of a function are contravariant and so an intersection is taken, a way to see this is to convert one of your parameters to an array, let's see it -


const urls = {
    inviteNewUser: ([{teamId, intent = 'invite'}]: [{teamId: string; intent?: Intent}]) => `/magic-link?intent=${intent}&teamId=${teamId}`,
    resetPassword: ({intent = 'reset-password'}: {intent?: Intent}) => `/magic-link?intent=${intent}`,
} as const

.... // Other content

    const url = urlCreator(args) // <- but here I get the error `Property 'teamId' is missing in type '{....`
    //          ^?const urlCreator: (__0: [{ teamId: string: intent?: any; }] & { intent?: any; }) => string

See? An intersection. The type of arg? That's a union of both the types instead of an intersection, i.e., -

const url = urlCreator(args) // <- but here I get the error `Property 'teamId' is missing in type '{....`
    //                 ^?const args: { teamId: string; Intent?: any } | { Intent?: any }

Which is why typescript complains with -

Argument of type '{ teamId: string; intent?: any; } | { intent?: any; }' is not assignable to parameter of type '{ teamId: string; intent?: any; }'. Property 'teamId' is missing in type '{ intent?: any; }' but required in type '{ teamId: string; intent?: any; }'.(2345)

Simplest way to solve this, if you don't wish to change your approach is just adding an as assertion for key parameter and removing the explicit typing of urlCreator i.e.,

const urlCreator = urls[key as "resetPassword"]

This resolves issue because the key here, instead of being of type "resetPassword" | "inviteNewUser" is explicitly asserted to be resetPassword due to which the type of urlCreator is of type ({ intent }: { intent?: any; }) => string and it accepts the arg parameter.

Otherwise, you may want to consider doing a major refactoring of types and maybe even function as well.

0xts
  • 2,411
  • 8
  • 16