Amin Paks
Amin Paks

Reputation: 276

TypeScript inference doesn't work properly

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

Answers (3)

Titian Cernicova-Dragomir
Titian Cernicova-Dragomir

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.

The solution

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

hgiasac
hgiasac

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

Matt McCutchen
Matt McCutchen

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

Related Questions