Nicolas Bouvrette
Nicolas Bouvrette

Reputation: 4757

How to get the correct type of a Typescript function argument based on another argument's generic type?

I tried to find other answers but could not find any solution to this very simple problem so I am sure I am missing something obvious.

I have a function that takes in a generic argument that can be either string or number and based on the type of this argument, the second argument will have a specific type. The code is very simple:


type OptionsNumber = {
  numberProp: number
}

type OptionsString = {
  stringProp: string
}

export const test = <T extends number | string>(
  input: T | undefined | null,
  options?: T extends string
    ? OptionsString
    : T extends number
    ? OptionsNumber 
    : never
): void => {
  if (typeof input === 'number') {
    // I am getting an error below because TypeScript is not able to apply the `OptionsNumber` type
    console.log(options.numberProp)
  }
}

Working code here: https://tsplay.dev/w17rAm

Basically, all I am hoping is that by checking the type of input TypeScript can know that options have the right type (just like in the options conditional type.

What am I missing?

Upvotes: 2

Views: 149

Answers (1)

jcalz
jcalz

Reputation: 328132

Your code might be simple, but the issues it raises are not. There are multiple problems that unfortunately stop it from working.


The first is that control flow analysis does not affect generic type parameters. When you check input with typeof input === 'number', the compiler will narrow the type of input from T to T & number (thanks to support for narrowing values of generic types added in TypeScript 4.3):

if (typeof input === 'number') {
  input; // (parameter) input: T & number
}

but nothing happens to T. The type of T does not get "re-constrained" to T extends number. And since T does not get narrowed, the type of options is unchanged. It can still be some subtype of OptionsString | OptionsNumber.

There are open feature requests asking for something better here, such as ms/TS#33014. But for now this is a design limitation of TypeScript.


Given your call signature, though, you don't need this to be generic. You could make the function arguments a rest parameter whose type is a union of tuple types, and you can even destructure that into the original input and options parameters:

export const test = (...[input, options]:
  [input: string, options?: OptionsString] |
  [input: number, options?: OptionsNumber]
): void => { /* snip */ }

test("abc", { stringProp: "def" }); // okay
test(123, { numberProp: 456 }); // okay
test("abc", { numberProp: 456 }); // error!

From the caller's side, this is just as good as the generic version. Unfortunately, your implementation problem persists:

export const test = (...[input, options]:
  [input: string, options?: OptionsString] |
  [input: number, options?: OptionsNumber]
): void => {
  if (typeof input === 'number') {
    console.log(options?.numberProp) // still error!
  }
}

So that's the second problem. Even if you could narrow T, it would fail, because the type of argument pair [input, options] is not considered to be a discriminated union by the compiler.

A discriminated union needs a discriminant property whose type is a singleton/unit/literal type or a union of such types, in at least one of the union members. (See microsoft/TypeScript#48500 for a source for that.)

Then when you check the discriminant property, it will narrow the rest of the properties via control flow analysis. And even in the case of destructured assignment to separate variables like input and options, this control flow analysis still works, thanks to support added in TypeScript 4.6.

So if input were of a literal type like, say, "abc" or 123, this would magically work:

export const test = (...[input, options]:
  [input: "abc", options?: OptionsString] |
  [input: 123, options?: OptionsNumber]
): void => {
  if (input === 123) {
    console.log(options?.numberProp) // okay!
  }
}

But the type of input is either string or number. These are not discriminant types, and so checking them does not do anything to the apparent type of the pair [input, options]. You can check input all you like, and it will never have an effect on the apparent type of options.


You can try to work around that by writing a custom type guard function that acts on the rest argument, where you explicitly tell the compiler how to narrow it:

function firstElementIsNumber(x: object): x is { 0: number } {
  return typeof (x as any)[0] === "number";
}

If you call firstElementIsNumber(something) and it returns true, then the compiler will narrow the apparent type of something to a type with a number property at index 0. Otherwise it will try to narrow something to a type without such a property. Let's test it:

export const test = (...args:
  [input: string, options?: OptionsString] |
  [input: number, options?: OptionsNumber]
): void => {
  if (firstElementIsNumber(args)) {
    const [input, options] = args;
    console.log(options?.numberProp) // okay
  } else {
    const [input, options] = args;
    console.log(options?.stringProp) // okay
  }
}

So that works just fine, hooray! Except, uh, it's kind of ugly. It's still impossible to write if (someTest(input)) { options?.numberProp }, because input has no effect on options. We have to leave args as a tuple type, test that tuple, and then destructure it into variables if we want.

That's about the best I can do if I want type safety from the compiler. The only unsafe code is that the compiler doesn't check the implementation of firstElementIsNumber() (custom type guard functions are never checked for anything except that they return a boolean), but as long as that's properly implemented then you can be fairly confident in the type safety of the test() function.


Since you can't do this ergonomically, then you will need to choose between style and safety. You always have the option of leaving your code mostly the same, and using type assertions to just tell the compiler what the type of options should be:

export const test = <T extends number | string>(
  input: T | undefined | null,
  options?: T extends string ? OptionsString : T extends number ? OptionsNumber : never
): void => {
  if (typeof input === 'number') {        
    console.log((options as OptionsNumber | undefined)?.numberProp)
  } else {
    console.log((options as OptionsString | undefined)?.stringProp)
  }
}

That compiles without error, and emits the same JavaScript as your original version. But remember, this is not verified as safe, it's just you making a claim about the types. You could make the wrong claim and the compiler wouldn't catch it:

export const test = <T extends number | string>(
  input: T | undefined | null,
  options?: T extends string ? OptionsString : T extends number ? OptionsNumber : never
): void => {
  if (typeof input !== 'number') { // 😱
    console.log((options as OptionsNumber | undefined)?.numberProp)
  } else {
    console.log((options as OptionsString | undefined)?.stringProp)
  }
}

So if you decide to use type assertions, be careful!


There you have it! Your problem was very simple, and the answer involves something like three separate design limitations of TypeScript and references to them. 😅

Playground link to code

Upvotes: 1

Related Questions