Reputation: 276
Let's imagine we have the following definitions, I don't understand why TypeScript still doesn't infer the types correctly!
Anyone knows how to write it correctly?
Notes:
* Make sure you turn on "Strict Null Check" option.
* I commented the code to explain the issue, if it's not clear please comment.
type Diff<T, U> = T extends U ? never : T;
type NotNullable<T> = Diff<T, null | undefined>;
type OptionType<T> = T extends NotNullable<T> ? 'some' : 'none';
interface OptionValue<T> {
option: OptionType<T>;
value: T;
}
let someType: OptionType<string>; // evaludates to 'some' correctly
let noneType: OptionType<undefined>; // evaluates to 'none' correctly
let optionSomeValue = { option: 'some', value: 'okay' } as OptionValue<string>; // evaluates correctly
let optionNoneValue = { option: 'none', value: null } as OptionValue<null>; // evaluates correctly
let getValue = <T>(value: T): (T extends NotNullable<T> ? OptionValue<T> : OptionValue<never>) =>
({ option: value ? 'some' as 'some' : 'none' as 'none', value });
let handleSomeValue = <T>(obj: OptionValue<T>) => {
switch (obj.option) {
case 'some':
return obj.value;
default:
return 'empty' as 'empty';
}
}
let someStringValue = 'check'; // type string
let someNumberValue = 22;
let someUndefinedValue: string | null | undefined = undefined;
let result1 = handleSomeValue(getValue(someStringValue)); // it is 'string' correctly
let result2 = handleSomeValue(getValue(someNumberValue)); // should be 'number' but it's 'number | empty'
let result3 = handleSomeValue(getValue(someUndefinedValue)); // it is 'empty' correctly;
Playground link
Upvotes: 4
Views: 1586
Reputation: 249486
There is a lot to unpack here, but the short version is that you need to use explicit type annotations to get this to work, inference has it's limits.
It is interesting to look at why this apparently works as you expect it to in some of the cases.
Firstly the inferred signature of handleSomeValue
is <T>(obj: OptionValue<T>) => T | "empty"
. Notice there is no relation between T
and whether 'empty'
is included in the return type or not, the result is always T | "empty"
. So why is 'empty'
sometimes missing and T
sometimes missing. Well that has to do with the rules of how unions are evaluated.
Lets consider the first example
let someStringValue = 'check'; // type string
let result1 = handleSomeValue(getValue(someStringValue));
here the T
to handleSomeValue
will be string
, so the result will be string | 'empty'
but "empty"
is a subtype of string
so string will eat up the literal type "empty"
(because it's redundant) and the result will be string
Now let's look at the third example which also appears to work:
let someUndefinedValue: string | null | undefined = undefined;
let result3 = handleSomeValue(getValue(someUndefinedValue)); // it is 'empty' correctly;
While here someUndefinedValue
appears to be typed as string | null | undefined
it is in fact not, if you hover over someUndefinedValue
in the second line you will see it is typed as undefined
. This is because flow analysis determines that the actual type will be undefined
because there is no path for the variable to be undefined
.
This means that getValue(someUndefinedValue)
will return OptionValue<never>
, so the T
in handleSomeValue
will be never
so we get never | 'empty'
. And since never
is a subtype of all types (see PR) never | 'empty'
will just evaluate to 'empty'
.
It's interesting to note that when someUndefinedValue
is actually string | undefined
the example fails to compile because getValue
will return 'OptionValue<string> | OptionValue<never>'
and the compiler will not be able to infer T
correctly.
let someUndefinedValue: string | null | undefined = Math.random() > 0.5 ? "" : undefined;
let result3 = handleSomeValue<string | never>(getValue(someUndefinedValue)); // Argument of type 'OptionValue<string> | OptionValue<never>' is not assignable to parameter of type 'OptionValue<string>'
With this understanding it becomes obvious why the second example does not work as expected.
let someNumberValue = 22;
let result2 = handleSomeValue(getValue(someNumberValue)); // should be 'number' but it's 'number | empty'
getValue
returns OptionValue<number>
so the T
in handleSomeValue
will be number
and the result will thus be number | 'empty'
. Since the two types in the union have no relation, the compiler will not try to simplify the union any further and will leave the result type as is.
A solution that works as you expect and the preserves the 'empty'
literal type is not possible because the union of string | 'empty'
will always be string
. We can prevent the simplification if we use branded types to add something to empty
to prevent the simplification. Also we will need an explicit type annotation for the return type that will correctly identify the return type:
type Diff<T, U> = T extends U ? never : T;
type NotNullable<T> = Diff<T, null | undefined>;
type OptionType<T> = T extends NotNullable<T> ? 'some' : 'none';
interface OptionValue<T> {
option: OptionType<T>;
value: T;
}
let someType: OptionType<string>; // evaludates to 'some' correctly
let noneType: OptionType<undefined>; // evaluates to 'none' correctly
let optionSomeValue = { option: 'some', value: 'okay' } as OptionValue<string>; // evaluates correctly
let optionNoneValue = { option: 'none', value: null } as OptionValue<null>; // evaluates correctly
let getValue = <T>(value: T): (T extends NotNullable<T> ? OptionValue<T> : OptionValue<never>) =>
({ option: value ? 'some' as 'some' : 'none' as 'none', value }) as any;
type GetOptionValue<T extends OptionValue<any> | OptionValue<never>> =
T extends OptionValue<never> ? ('empty' & { isEmpty: true }) :
T extends OptionValue<infer U> ? U: never ;
let handleSomeValue = <T extends OptionValue<any> | OptionValue<never>>(obj: T) : GetOptionValue<T>=> {
switch (obj.option) {
case 'some':
return obj.value;
default:
return 'empty' as GetOptionValue<T>;
}
}
let someStringValue = 'check'; // type string
let result1 = handleSomeValue(getValue(someStringValue)); // it is 'string' correctly
let someNumberValue = 22;
let result2 = handleSomeValue(getValue(someNumberValue)); //is number
let someStringOrUndefinedValue: string | null | undefined = Math.random() > 0.5 ? "" : undefined;
let result3 = handleSomeValue(getValue(someStringOrUndefinedValue)); // is string | ("empty" & {isEmpty: true;})
let someUndefinedValue: undefined = undefined;
let result4 = handleSomeValue(getValue(someUndefinedValue)); // is "empty" & { isEmpty: true; }
Upvotes: 1
Reputation: 2233
Because T
is not NotNullable nor Diff<T, U>
. If you input type T
, it will alway return the same. You must instruct the compiler how to do
You must define new type that wrap OptionValue<T>
:
type OptionValue2<T> = OptionValue<NotNullable<T>>;
let getValue = <T>(value: T): OptionValue2<T> => ({
option: value ? "some" : "none",
value
});
let someValue: undefined;
let value = getValue(someValue); // this type will be OptionValue<never>
// still use OptionValue
let handleSomeValue = <T>(obj: OptionValue<T>): SomeValueReturn<T> => {
switch (obj.option) {
case "some":
return obj.value as T;
default:
return "empty" as "empty";
}
};
let someUndefinedValue: string | null | undefined = undefined;
For last case, Typescript compiler evaluate type only. It can not know if the result is string or null. You can not expect dynamic value evaluation
Upvotes: 0
Reputation: 30879
The inferred return type of handleSomeValue
is computed as the union of the types of all of the returned expressions, i.e., T | "empty"
. At each call site, this return type is instantiated with the type argument for T
. TypeScript does not re-analyze the body of handleSomeValue
for each call to see which cases of the switch are reachable depending on T
. If you wanted, you could annotate handleSomeValue
like this:
type SomeValueReturn<T> = T extends NotNullable<T> ? T : "empty";
let handleSomeValue = <T>(obj: OptionValue<T>): SomeValueReturn<T> => {
switch (obj.option) {
case 'some':
return obj.value as SomeValueReturn<T>;
default:
return 'empty' as SomeValueReturn<T>;
}
}
But I don't really understand what you are trying to achieve.
Upvotes: 0