Home > Net >  Function's return type as union of objects properties types
Function's return type as union of objects properties types

Time:11-17

Somewhat new to TS. Wanted to type a function whose return type is union of objects properties types like this:

get({age:9,name:"Nick"})
// Return type should be number | string

Tried this:

let get = <T extends {age:number, name:string}>(x:T):T[keyof T]=>{  
   return x.age
}

But errors on x.age:

Type 'number' is not assignable to type 'T[keyof T]'

When I return null as any from function the functions return type was correctly inferred though.

Why I get that error?

CodePudding user response:

When dealing with operations on generic-typed values, there is a tradeoff the compiler needs to make. Either the result of the operation can stay generic, which produces potentially very precise and accurate types, but such complex generic types are difficult for the compiler to analyze; or the generic type can first be widened to its constraint so that the resulting type is specific, which is easier to analyze but can lead to a loss of precision. The compiler uses heuristic rules to determine when to propagate generics and when to widen to specifics.

For example, inside

let get = <T extends { age: number, name: string }>(x: T) => {
  const age = x.age // what type is age?
  return age;
}

what type should age be? Since x is of type T, and you are indexing into it with a key of type "age", then the precise generic type of x is the indexed access type T["age"]. On the other hand, we know that x's type is a subtype of {age: number, name: string}. And so we can widen x to that type, in which case the type of x.age is {age: number, name: string}["age"], which is just number. So the two obvious possibilities here are: either age stays generic and is of type T["age"], or it is widened to a more immediately usable specific type number.

What does the compiler do?

let get = <T extends { age: number, name: string }>(x: T): T[keyof T] => {
  const age = x.age;
  // const age: number
  return age; // error! Type 'number' is not assignable to type 'T[keyof T]'.
}

It is widened to number. This behavior is documented in this comment on microsoft/TypeScript#33181, a similar issue to the one you're seeing. Paraphrasing slightly:

Property access and element access return the corresponding property type of the constraint, so [ x.age has type number ], this is why the the [ return statement ] fails. Once you fall back to something concrete you can't later index with something generic.

That is, when you return age, the compiler will see that you've returned a value of type number. And unfortunately, number is not necessarily assignable to T[keyof T], as shown here:

interface Centenarian {
  name: string,
  age: 100,
}
declare const oldManJenkins: Centenarian;
const g = get(oldManJenkins);
// const g: string | 100

A Centenarian has an age which is the always the literal type 100, which is narrower than number. Inside get() the compiler has widened age from "whatever T["age"] turns out to be" to number, and number is not assignable to string | 100 (because, say, 99.5 is a number, but it's not a string | 100).

So that's why you get the error.


As for how to deal with it, you can do something similar to what's shown in microsoft/TypeScript#33181... explicitly annotate an age variable with the desired generic type, so the compiler has a hint not to widen it:

let get = <T extends { age: number, name: string }>(x: T): T[keyof T] => {
  const age: T['age'] = x.age; // okay
  return age; // okay
}

Now age is seen to be of type T['age'], which is seen to be assignable to T[keyof T], and the function compiles without error.

Playground link to code

  • Related