1

I have a fairly simple case where Typescript doesn't infer the types. I'd like to know why Typescript behaves this way.

Case where Typescript type inference fails

If you have the following classes declarations:

declare class Swine
{
    grunt(): void
}

declare abstract class Part<T> {}

declare class Hoof extends Part<Swine> {}

And the following function:

declare function getOwner<T>(part: Part<T>): T;

Then the following code fails in the Typescript compiler:

getOwner(new Hoof())
  .grunt();

This is because Typescript infers the return type of getOwner to be unknown, instead of Swine.

The strange but effective solution

If I merely add a property animal: T to the declaration of Part, though, the code will start to will work.

So in other words, if the declaration of Part becomes:

declare abstract class Part<T>
{
  animal: T
}

Typescript will be able to infer the return type of getOwner(new Hoof()). I have verified this behavior for Typescript 3.9.7 and 4.1.3.

Question

Why does Typescript behave this way? Should it not keep the generic type information, even when the T does not occur in any of the class members' signatures?

DavidDuwaer
  • 694
  • 1
  • 7
  • 18

1 Answers1

2

See the TypeScript FAQ entry on unused type parameters for a canonical answer.

TypeScript's type system is largely structural and not nominal. That means if you are comparing type A and B, they are considered to be the same type if and only if they have the same structure (e.g., both are object types with members of the same keys and same value types). It does not matter what types A and B are named (e.g., the fact that I use names like "A" and "B" isn't relevant) or where they are declared (e.g., having separate declarations like interface A { a: string } and interface B { a: string } doesn't make them separate types).


In particular, given the declaration

declare abstract class Part<T> { }

You can see that no matter what type you specify for T, the resulting type is an empty object type with no members, and therefore is the same type as just {}. The compiler really doesn't see any difference between Part<swine> and Part<string> or anything else.

To see this, note that TypeScript will not let you redeclare a var with a type annotation of a different type from the original declaration:

var bad: Swine;
var bad: string; // error!
//  ~~~
//  Subsequent variable declarations must have the same type.

But none of the following yields any compiler error:

var okay: Part<Swine>;
var okay: {}; // no error
var okay: Part<string>; // no error
var okay: Part<unknown>; // no error

So they really are the same type in TypeScript. And that means when you hand the compiler a value of type Part<Swine>, there's nothing Swine-like about it. The name Part<Swine> is just a name, and should no bearing on how the compiler processes the type. Trying to infer T from a value of type Part<T> is therefore impossible in principle; the inference fails, and you get unknown.

Adding a property of type T to Part<T> changes everything. Now there is a structural difference between Part<Swine> and Part<string> or Part<unknown> or {}, and the compiler has a handle on which to determine T from Part<T>.


If we imagine taking this down from the type level into the value level by looking at functions that take string representations of types and turns them into other string representations of types, the analog of what you were doing is something like this:

function part(T: string): string {
  return "{}";
}
const hoof = part("Swine");
function getOwnerType(partT: string): string {
  // how would you implement this ???
  return "unknown";
}
console.log(getOwnerType(hoof)); // unknown

The translation of declare abstract class Part<T> {} is just (T: string) => "{}"). Since part("Swine") produces the same output as part("string"} or anything else, namely the string "{}", there's no way to write the reverse function getOwnerType(). If, on the other hand, your part() function has an output that actually depends on its input, things become more reasonable:

function part(T: string): string {
  return "{animal: " + T + "}";
}
const hoof = part("Swine");
function getOwnerType(partT: string): string {
  return (partT.match(/^{animal: (.*)}$/) ?? ["", "unknown"])[1];
}
console.log(getOwnerType(hoof)); // Swine

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360