Search code examples
typescripttypescript-types

The difference between recursive constraint and specific constraint


There is the following code:

interface CascaderOption {
  children?: CascaderOption[]
}

interface CascaderProps<T extends CascaderOption> {
  getLabel: (item: T) => string
}

const Cascader = <T extends CascaderOption>({ getLabel }: CascaderProps<T>) => {
  const recurse = (nodes: T[]) => {
    nodes.forEach((node) => {
      console.log(getLabel(node))
      recurse(node.children!!)
    })
  }

  return <></>
}

Report a error:

TS2345: Argument of type CascaderOption[] is not assignable to parameter of type T[]
Type CascaderOption is not assignable to type T
CascaderOption is assignable to the constraint of type T, but T could be instantiated with a different subtype of constraint CascaderOption

error

But when I switch to type:

interface CascaderProps<T extends { children?: T[] }> {
  getLabel: (item: T) => string
}

const Cascader = <T extends { children?: T[] }>({getLabel}: CascaderProps<T>) => {
  const recurse = (nodes: T[]) => {
    nodes.forEach((node) => {
      recurse(node.children!!)
      console.log(getLabel(node))
    })
  }

  return <></>
}

The code works fine, can you explain why?


2025-2-24 update

My confusion is as follows, my intention is that the generic type T has a property children: T[], so that I can call t.children within the function, So I used Typescript Generic Constraints.

It seems to me that T inherits a parent class B. But there are two ways to write class B.CascaderOption and {children?: T[]}. Don't both represent class B?

2025-2-24 update

I don't really care about the difference between an interface and a type. I care about the difference between a recursive constraint (where T extends F<T>) and a specific constraint (where T extends U)). It doesn't matter if U is an interface or a type. which is different from this answer.


Solution

  • Given

    interface CascaderOption {
      children?: CascaderOption[]
    }
    

    Let's look at two different types, A and B, both of which are recursive types, but recursive in different ways, and both of which are assignable to CascaderOption. A is the simple one:

    // A's children are A[]
    interface A {
      a: string;
      children: A[]
    }
    const a: A = { a: "abc", children: [{ a: "def", children: [] }] }
    

    Here you can see that every A has a children property of type A[]. So if you have an A, you know that each of its children is also an A. And an A has an a property of type string. So a.a exists, and so does a.children[0].a.

    But B is a bit more complicated:

    // B's children are C[] but *not* necessarily B[]
    interface B extends C {
      b: string;
    }
    interface C {
      children: C[]
    }
    const b: B = { b: "ghi", children: [{ children: [] }] }
    

    Here you can see that every B has a children property of type C[], where C is itself recursive and has a children property of type C[]. But while B is known to have a b property of type string, C is not known to have such a property. So b.b exists, but b.children[0].b does not exist. Just because a B has a b property, it does not mean that B's children have such a property.

    You can verify that both A and B are assignable to CascaderOption. Again, A is the easy one, because it looks very much like CascaderOption with an extra property. B is tougher, but look at C, which is just CascaderOption with a required children property. So C is assignable to CascaderOption. And since B extends C, then B is also assignable to CascaderOption.

    So we've got two CascaderOption-compatible types: A, which has an a property and whose children are A[]; and B, which has a b property and whose children are C[].


    The difference between A and B is exactly the problem you're running into. Let's use your types but add a value of type T to CascaderProps<T> so that we can actually use recurse() and see the issue:

    interface CascaderProps<T extends CascaderOption> {
      getLabel: (item: T) => string,
      t: T
    }
    
    const Cascader = <T extends CascaderOption>(
      { getLabel, t }: CascaderProps<T>) => {
      const recurse = (nodes: T[]) => {
        nodes.forEach((node) => {
          console.log(getLabel(node))
          recurse(node.children!!) // compiler error
        })
      }
      recurse([t])
    }
    

    This is basically the same as your code. Now, compare the behavior for the following two calls:

    Cascader({
      getLabel(a: A) { return a.a.toUpperCase() },
      t: a
    });
    
    Cascader({
      getLabel(b: B) { return b.b.toUpperCase() },
      t: b
    });
    

    In the first call, T is inferred to be A. And the call completes successfully, because recurse(node.children) always ends up recursing down into A[], and each A has an a property, so a.a.toUpperCase() always succeeds.

    In the second call, T is inferred to be B. And the second call crashes at runtime with a TypeError that b.b is undefined. That's because once recurse processes b.children, it hits a value of type C without a b property. So b.b.toUpperCase() is dereferencing undefined. Oops! This is a big problem. And that's why recurse(node.children) has a compiler error. It is exactly as the error states:

    'CascaderOption' is assignable to the constraint of type 'T', 
    but 'T' could be instantiated with a different subtype of constraint 
    'CascaderOption'.(2345)
    

    It's telling you that node.children is only known to be of type CascaderOption, but that's not necessarily of type T, because T might be some more specific CascaderOption with other properties. And B is exactly such a case, where CascaderOption is not assignable to B.


    You can fix this by only allowing Ts whose children property is an array of T itself. That's a recursive constraint, also known as an F-bounded constraint:

    const Cascader = <T extends { children?: T[] }>(
      { getLabel, t }: CascaderProps<T>) => {
      const recurse = (nodes: T[]) => {
        nodes.forEach((node) => {
          recurse(node.children!!) // no compiler error
          console.log(getLabel(node))
        })
      }
      recurse([t])
    }
    

    Now there's no compiler error, because node.children is (assuming it's not undefined an array of T elements. And now the difference between A and B means that A matches the recursive constraint but B does not match it:

    Cascader({
      getLabel(a: A) { return a.a.toUpperCase() },
      t: a
    }); // okay
    
    Cascader({
      getLabel(b: B) { return b.b.toUpperCase() }, // compiler error!
      t: b // compiler error!
    }); 
    // Type 'B' is not assignable to type '{ children?: B[] | undefined; }'.
    

    The first call succeeds as expected. And now the second call, the one we know crashes at runtime, is prevented by the compiler. It complains, rightly, that B is not assignable to {children?: B[]}, and therefore Cascader() should not accept it. Since this possibility is caught at the call site, there's no reason to worry about it inside recurse() anymore.


    To put it in terms of more concrete real-world things, CascaderOption/C is just a human being, whose children and descendants are also human beings. And A is a human being with some kind of incredibly dominant trait a such that all of A's children and descendants also have that trait. But B is a human being with a recessive trait b such that all of B's children are just human beings, who may or may not have trait b.

    The constraint T extends CascaderOption just assumes that T is any kind of human being, but the recurse() function further assumes that all descendants of T are also T. And that's true for A, but not for B. If you want to allow A and prohibit B, then it's not enough to restrict T to just human beings T extends CascaderOption, but also to restrict it to human beings with incredibly dominant traits, such that T extends { children?: T[] }.

    Those are different constraints. The recursive one is much stronger.


    Playground link to code