Search code examples
typescripttypescript-generics

Typescript complaining 'T' could be instantiated with a different subtype of constraint 'MyType'


I'm trying to understand this error, but I can't seem to make sense of it... I'm using some Typescript features that are fairly new to me (conditional types), so I could just be misunderstanding how they work.

Any help would be greatly appreciated! 🙌

Here's my code:

type PrimaryCell = "primary" | "header";
type BorderCell = "top" | "bottom";
type AnyCell = PrimaryCell | BorderCell;

type Cell<T extends AnyCell, U extends string | string[]> = {
  content: U;
  type: T;
};

type TallCell<T extends PrimaryCell> = Cell<T, string[]>;
type ShortCell<T extends AnyCell> = Cell<T, string>;

// This is a conditional type for determining which cell types are allowed given a cell type
type TallOrShort<T extends AnyCell> = T extends PrimaryCell
  ? TallCell<T>[] | ShortCell<T>[]
  : ShortCell<T>[];

class RegularRow<T extends PrimaryCell, U extends TallOrShort<T>> {
  cells: U;
  type: T;

  constructor(cells: U) {
    this.cells = cells;
    this.type = this.cells[0].type; // <-- ERROR
  }
}

Here's the error:

Type '"primary" | "header"' is not assignable to type 'T'.
  '"primary" | "header"' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'PrimaryCell'.
    Type '"primary"' is not assignable to type 'T'.
      '"primary"' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'PrimaryCell'.ts(2322)

Here's my temporary fix (not ideal)

this.type = this.cells[0].type as T;

If there's a better way to implement this then, I am happy to hear suggestions.

My goal is to use Typescript to enforce that the RegularRow class should have an array of cells that can be either be Tall or Short (but not both) and the Cell type "primary", "header", "top", or "bottom" should dictate whether the cells can be Tall or Short. That's where the TallOrShort conditional type comes into play.

I don't usually get this deep into Types, but I'm making a library and figured it would be a good opportunity to learn more advanced Typescript features.

Thanks in advance!

Things I've Tried

I've tried a variety of things to solve it. It seems that this type is causing some problems.

type TallOrShort<T extends AnyCell> = T extends PrimaryCell
  ? TallCell<T>[] | ShortCell<T>[]
  : ShortCell<T>[];

Changing the TallCell<T>[] to TallCell<T> or pretty much anything else makes the error go away. Not sure why that is.


Solution

  • The problem with

    type TallOrShort<T extends AnyCell> = T extends PrimaryCell
      ? TallCell<T>[] | ShortCell<T>[]
      : ShortCell<T>[];
    

    inside the implementation of RegularRow<T, U> is that T and U are generic, and RegularRow<T, U> is a conditional type. TypeScript tends to defer the evaluation of generic conditional types. It can't simply evaluate the type, since it doesn't know what T is yet. And it doesn't try to analyze the type to decide a generic conditional type much because it's hard in general to pull useful invariants out of such types; generic conditional types can compute lots of crazy type functions. When you wrote TallOrShort<T>, you intended to convey that, no matter what T is, TallOrShort<T> will be an array of a Call<T, any>, so that type of type T. But that information is not explicitly written in that definition. You need to analyze the type to figure that out. TypeScript can't do that.


    My suggestion here would be to rewrite TallOrShort<T> so that it directly encodes such information. For example:

    type TallOrShort<T extends AnyCell> =
      Cell<T, string | (T extends PrimaryCell ? string[] : never)>[]
    

    There's still a conditional type in there, but it's pushed down into an inner scope. TallOrShort<T> is Cell<T, ???>[], and that's all you need for your code to compile:

    class RegularRow<T extends PrimaryCell, U extends TallOrShort<T>> {
      cells: U;
      type: T;
    
      constructor(cells: U) {
        this.cells = cells;
        this.type = this.cells[0].type; // okay
        //                        ^?(property) type: T extends PrimaryCell
      }
    }
    

    The U parameter to Cell is string | (T extends PrimaryCell ? string[] : never); if T is PrimaryCell then you get Cell<T, string | string[]>, whereas if T isn't PrimaryCell then you get Cell<T, string>. This is similar to TalCell<T>[] | ShortCell<T>[] vs ShortCell<T>[] (but not identical; one is an array of unions and the other is a union of arrays, but hopefully that distinction doesn't matter much here).

    To some extent the conditional type will always be hard for the compiler to analyze if it's generic, but if we can confine the type to an inner scope, then the compiler can evaluate more of the type before it gives up.

    Playground link to code