Reputation: 4757
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
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. 😅
Upvotes: 1