最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

typescript - The difference between recursive constraint and specific constraint - Stack Overflow

programmeradmin0浏览0评论

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

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.

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

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.

Share Improve this question edited Feb 24 at 13:42 jcalz 332k29 gold badges443 silver badges442 bronze badges asked Feb 23 at 12:54 SageJustusSageJustus 1,10410 silver badges29 bronze badges 11
  • @jcalz you're right. – SageJustus Commented Feb 23 at 14:05
  • Can you explain why recurse cares about nodes being of type T[] instead of type CascaderOption[]? T extends {children?: T[]} means T might have more properties than just children, or that the children property could be narrower than T[], but if you're never using that restriction, then why write it? See this playground link. The B type matches T extends {children?: CascaderOption[]} but not T extends {children?: T[]}, so one compiles and the other doesn't. TS is working as intended, but I don't understand your use case. Please edit to clarify. – jcalz Commented Feb 23 at 14:10
  • @jcalz This is just a minimal example, and T could potentially be used by more functions, for example: getLabel(T). – SageJustus Commented Feb 23 at 14:24
  • I don't understand getLabel(T), that's a type as a value? A minimal reproducible example shouldn't be so minimal as to make the code meaningless. Right now recurse doesn't care about T. I didn't ask why Cascader accepts T instead of CascaderOption? I asked why recurse does. If recurse could do something with other properties of T, please show something in the example. Or, we could take it for granted that recurse does care about T without being shown, in which case the answer will have to hand-wave the problem (e.g., my playground link from prev comment, where Cascader(b) is disallowed) – jcalz Commented Feb 23 at 14:29
  • @jcalz I add CascaderProps code. – SageJustus Commented Feb 23 at 14:39
 |  Show 6 more comments

1 Answer 1

Reset to default 2

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

发布评论

评论列表(0)

  1. 暂无评论